<!DOCTYPE html>
<!--
 ================================================================
 まじん式スライド  v4.4b（書籍版）
 『秒速で仕事が片付く まじん式生成AI資料作成術』（KADOKAWA）専用
 (c) Majincraft（マジクラ）/ まじん  - All rights reserved.

 ■ このコードについて
  本コードは、本書のために専用に作り直したものです。
  note で配布しているものとは中身が異なります。
  本書の手順で作業される場合は、必ず本書の書誌ページ（→6ページ）から
  ダウンロードしたファイルをお使いください。

 ■ ご利用の範囲
  ・本コードのライセンスは、本書1冊のご所有と一体です。1冊につき1名さまが
    ご利用いただけます。
  ・複数の方でお使いになる場合は、ご利用人数分の書籍のご購入が必要です。
  ・本書を売却・譲渡された場合、本コードをご利用いただく権利も失われます。
    以後、お手元のコードはご利用いただけません。
  ・法人・団体でのご利用は、ご利用人数分の書籍をご購入いただくか、
    マジクラ法人プランへのご加入が必要です。
      マジクラ（法人プラン）: https://note.com/majin_108/membership/join

 ■ ここに書かれていない事項
  本書および本ヘッダーに記載のない事項については、下記の記事内の
  利用に関する記述が適用されます。
      https://note.com/majin_108/n/n1f5577c4e02d
 ================================================================
-->
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Google Slide Generator</title>
  <base target="_top">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&family=Roboto:wght@900&display=swap" rel="stylesheet">
  <script src="https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/upng-js@2.1.0/UPNG.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
  <script>
    pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
  </script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
  <script src="https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js"></script>
  <!-- PptxGenJS: PptxExportEngine(パワーポイント変換エンジン)が依存。分解結果→レイアウト済みPPTX→Googleスライド変換の高速パス用。 -->
  <script src="https://cdn.jsdelivr.net/gh/gitbrent/PptxGenJS@3.12.0/dist/pptxgen.bundle.js"></script>
  <!-- ===== NLM_ENGINES_START (auto-generated by build_inject_nlm.py from nlm_modules/*.js — DO NOT EDIT BY HAND) ===== -->
  <script id="nlm-text-engine">
;(function (__realWindow) {
  if (!__realWindow.NLM) __realWindow.NLM = {};
  // === サンドボックス: window を Proxy 化(原本エンジンは無改変で読み書きできる) ===
  var __UI_EVENTS = { paste: 1, resize: 1, DOMContentLoaded: 1 };
  var __overlay = Object.create(null); // エンジンが書く window.X はここに隔離(実window非汚染)
  var __win = new Proxy(__realWindow, {
    get: function (t, p) {
      if (p in __overlay) return __overlay[p];
      if (p === 'addEventListener') {
        return function (type, fn, opts) { if (__UI_EVENTS[type]) return; return t.addEventListener(type, fn, opts); };
      }
      var v = Reflect.get(t, p); // 受け手=実window: getter(document/navigator等)の this が実windowになり Illegal invocation を回避
      return (typeof v === 'function') ? v.bind(t) : v;
    },
    set: function (t, p, val) { __overlay[p] = val; return true; },
    has: function (t, p) { return (p in __overlay) || (p in t); }
  });
  (function (window) {
/* ===== BEGIN PureTextProcessingEngine.js (verbatim copy from nlm_modules/PureTextProcessingEngine.js + build時 majin-patch) ===== */

class PureTextProcessingEngine {
    constructor(params = {}) {
        this.params = {
            threshold: 70,
            splitObjects: true,
            showAnalysis: true,
            sharpenImage: false,
            detectInverted: true,
            keepBackground: true,
            forceBlackText: true,
            useOriginalForOcr: true,
            debugMode: false,
            ...params
        };
        this.isReady = true;
    }

    async init() { return Promise.resolve(); }

    /**
     * OCR生テキストの後処理（共通ロジック）
     *
     * メインのOCR処理 (startBatchOCR) と、単独OCR (processRegionToJSON 後の
     * 補正、エディタからのTesseract直接OCR等) の両方から呼び出されます。
     * ここに補正ルールを追加すると、両方の経路に自動反映されます。
     *
     * 含まれる処理:
     *  - 重複改行の除去
     *  - 丸数字 (①〜⑳⓪) → 半角数字
     *  - 数字内カンマ後の空白除去
     *  - 誤認識単語の補正マップ (伝絹→伝統, 修報源→情報源, ...)
     *  - "Al" → "AI", "Gemin" → "Gemini"
     *  - クォート・コロン・括弧の正規化
     *  - 末尾重複文字の整形 (text.length >= 10 の場合)
     *  - 特定イラスト誤認識パターン (ユLに一, つデ, ...) の除去
     *  - trim
     *
     * @param {string} rawText Tesseract等の生OCR結果
     * @returns {string} 後処理済みテキスト
     */
    static postProcessOcrText(rawText) {
        if (rawText == null) return '';
        let text = String(rawText);

        // 重複改行除去 (例: "あ\nあ" -> "あ")
        text = text.replace(/(.)[\r\n]+\1/g, '$1');

        // 丸数字 → 半角数字
        text = text.replace(/[①-⑳⓪]/g, m => ({'①':'1','②':'2','③':'3','④':'4','⑤':'5','⑥':'6','⑦':'7','⑧':'8','⑨':'9','⑩':'10','⑪':'11','⑫':'12','⑬':'13','⑭':'14','⑮':'15','⑯':'16','⑰':'17','⑱':'18','⑲':'19','⑳':'20','⓪':'0'}[m]));

        // 数字内のカンマ後スペース除去 (例: "3, 400" -> "3,400")
        text = text.replace(/(\d),\s+(\d)/g, '$1,$2');

        // 誤認識単語の補正マップ
        const correctionMap = {
            '伝絹': '伝統', '修報源': '情報源', '余い': '低い', 'YaAHOO!': 'YAHOO!',
            '織路': '線路', '聰減': '軽減', '焦練': '熟練', '脹': '服',
            '痕導': '指導', '郵侯': '郵便', '手維き': '手続き', '鄧': '郵', '国雛': '困難',
            '証験': '経験', '権戚性': '権威性', '権戦性': '権威性', '信輝性': '信頼性', '信曽性': '信頼性',
            'グークル': 'グーグル', 'グークグル': 'グーグル', '探え、': '据え、', '再定議': '再定義', 'う5億人': '5億人', 'う⑤億人': '5億人',
            'Wms': '', 'さえー C竹': '', '較捻観': '転換点', '損えています': '据えています',
            '撫昴': '提供', '濾しく燃合': '激しく競合', '厳垣な倫理的宿蚕': '厳格な倫理的審査',
            'ユュータでわずか約2時間で完了させました': 'ュータでわずか約2時間で完了させました。',
            'ユLに一': '', 'つデ': '', '一一9': '', '形 ABc': '', '哉森e': '',
            '物偏高': '物価高', '本回脇': '本図解', 'を聰に': 'を軸に', '遥い': '違い',
            '啓約': '要約', 'に八けて': 'に長けて', '甘けて': '長けて', '活莞': '活発',
            '3.400': '3,400', '3, 400': '3,400', '4, 500': '4,500', '基本性能と': '基本機能と'
        };
        for (const [wrong, correct] of Object.entries(correctionMap)) {
            text = text.split(wrong).join(correct);
        }

        text = text.replace(/Al(?![a-zA-Z])/g, 'AI');
        text = text.replace(/\bGemin\b/g, 'Gemini');
        text = text.replace(/Gemin$/g, 'Gemini');
        text = text.replace(/^— (?=.)/, '→ ');
        text = text.replace(/`([Ss])/g, "'$1");
        text = text.replace(/`/g, (m,o,s) => (/[a-zA-Z]/.test(s[o-1]||'') && /[a-zA-Z]/.test(s[o+1]||'')) ? m : "「");
        text = text.replace(/,([^ ])/g, ', $1');
        text = text.replace(/([0-9０-９])\s+\./g, '$1.').replace(/([0-9０-９])\.([^\d\s])/g, '$1. $2');
        text = text.replace(/:\)/g, ')');
        text = text.replace(/:([一-龠ぁ-んァ-ヶ])/g, ': $1');
        text = text.replace(/([一-龠ぁ-んァ-ヶ]):([^\s])/g, '$1: $2');
        text = text.replace(/([一-龠ぁ-んァ-ヶ]):/g, '$1 :');
        text = text.replace(/\s+[(\[「（]$/, '');

        if (text.length >= 10) {
            text = text.replace(/([^0-9])\1$/, '$1');
            text = text.replace(/[ヘへ][ヘへ]$/, 'へ');
            text = text.replace(/%(\d+)/g, '%');
            text = text.replace(/(\d)\1%$/, '$1%');
        }

        // 特定イラスト誤認識パターンの完全除去
        const noSpaceText = text.replace(/\s+/g, '');
        if (/^(ユLに一|つデ|一一9|形ABc|哉森e|さえーC竹|Wms)$/i.test(noSpaceText)) {
            text = '';
        }

        return text.trim();
    }

    /**
     * OCR用画像の前処理（2x拡大 + 白背景 + 両端パディングテキスト付与）
     *
     * メインOCR (startBatchOCR 内の runOCRWithScheduler) と
     * 単独OCR (エディタの再テキスト変換) の両方から呼び出される共通実装。
     *
     * @param {HTMLCanvasElement} sourceCanvas
     * @param {string} paddingText "ABC" または "ABCあいう"
     * @returns {HTMLCanvasElement} パディング付与済みの新しいCanvas
     */
    static prepareImageForOCR(sourceCanvas, paddingText = "ABC") {
        const scale = 2.0;
        const scaledWidth = sourceCanvas.width * scale;
        const scaledHeight = sourceCanvas.height * scale;
        const scaledCanvas = document.createElement('canvas');
        scaledCanvas.width = scaledWidth;
        scaledCanvas.height = scaledHeight;
        const sCtx = scaledCanvas.getContext('2d');
        sCtx.imageSmoothingEnabled = true;
        sCtx.imageSmoothingQuality = 'high';
        sCtx.drawImage(sourceCanvas, 0, 0, scaledWidth, scaledHeight);

        const outerPadding = 50;
        const innerPadding = 10;

        const fontSize = Math.max(20, scaledHeight * 0.90);
        const dummyText = paddingText;

        const tempCtx = document.createElement('canvas').getContext('2d');
        tempCtx.font = `bold ${fontSize}px sans-serif`;
        const textWidth = tempCtx.measureText(dummyText).width;

        const newWidth = outerPadding + textWidth + innerPadding + scaledWidth + innerPadding + textWidth + outerPadding;
        const contentHeight = Math.max(scaledHeight, fontSize);
        const newHeight = contentHeight + (outerPadding * 2);

        const newCanvas = document.createElement('canvas');
        newCanvas.width = newWidth;
        newCanvas.height = newHeight;
        const ctx = newCanvas.getContext('2d');

        ctx.fillStyle = "#ffffff";
        ctx.fillRect(0, 0, newWidth, newHeight);

        ctx.font = `bold ${fontSize}px sans-serif`;
        ctx.fillStyle = "#000000";
        ctx.textBaseline = "middle";

        const centerY = newHeight / 2;
        ctx.fillText(dummyText, outerPadding, centerY);

        const targetX = outerPadding + textWidth + innerPadding;
        const targetY = (newHeight - scaledHeight) / 2;
        ctx.drawImage(scaledCanvas, targetX, targetY);

        ctx.fillText(dummyText, targetX + scaledWidth + innerPadding, centerY);
        return newCanvas;
    }

    /**
     * OCR結果からパディングテキストマーカーを除去
     * @param {string} rawText
     * @param {string} paddingText
     * @returns {string}
     */
    static stripOcrPaddingMarker(rawText, paddingText = "ABC") {
        if (paddingText === "ABC") {
            return rawText.replace(/^[\s\S]*?A\s*B\s*C\s*/, "")
                          .replace(/\s*A\s*B\s*C[\s\S]*?$/, "")
                          .trim();
        } else if (paddingText === "ABCあいう") {
            const pattern = "A\\s*B\\s*C\\s*あ\\s*い\\s*う";
            const reStart = new RegExp(`^[\\s\\S]*?${pattern}\\s*`);
            const reEnd = new RegExp(`\\s*${pattern}[\\s\\S]*?$`);
            return rawText.replace(reStart, "").replace(reEnd, "").trim();
        }
        return rawText.trim();
    }

    /**
     * 単一画像領域に対するOCRパイプライン（前処理→OCR→マーカー除去→リトライ判定）
     *
     * メインOCR (startBatchOCR の セグメントループ内) と
     * 単独OCR (エディタの再テキスト変換のTesseract直接OCR) の両方から呼び出される共通エントリポイント。
     *
     * パイプライン:
     *  1. prepareImageForOCR で 2x拡大 + 白背景 + "ABC" 両端パディング
     *  2. Tesseract recognize
     *  3. "ABC" マーカーを strip
     *  4. リトライ判定（3桁数字 or 記号開始の10文字以上）
     *  5. 必要なら "ABCあいう" パディングで再OCR + strip
     *
     * ※ postProcessOcrText (誤認識補正等) は呼ばないので、呼び出し側でかけてください。
     *    （セグメント結合後にまとめてかけるため）
     *
     * @param {HTMLCanvasElement} sourceCanvas - OCR対象画像
     * @param {Object} scheduler - Tesseract scheduler
     * @returns {Promise<{ cleanText, rawText, paddedImage }>}
     */
    static async runOcrPipeline(sourceCanvas, scheduler) {
        const runOnce = async (padText) => {
            const paddedCanvas = PureTextProcessingEngine.prepareImageForOCR(sourceCanvas, padText);
            const finalDataUrl = paddedCanvas.toDataURL('image/jpeg', 1.0);
            const result = await scheduler.addJob('recognize', finalDataUrl);
            const rawText = result.data.text.trim();
            const cleanText = PureTextProcessingEngine.stripOcrPaddingMarker(rawText, padText);
            return { cleanText, rawText, paddedImage: paddedCanvas };
        };

        // 1回目: "ABC"パディング
        let result = await runOnce("ABC");

        // リトライ判定
        let shouldRetry = false;
        let retryReason = "";
        const txt = result.cleanText || "";
        if (txt) {
            if (/^\d{3}$/.test(txt)) {
                shouldRetry = true; retryReason = "3桁数字";
            } else if (txt.length >= 10 && /^[%/:;.,\-+=()[\]{}<>!?@#$&]/.test(txt)) {
                shouldRetry = true; retryReason = "記号開始の長文";
            }
        }

        if (shouldRetry) {
            console.log(`[PureEngine] 再OCR実行: ${retryReason} -> "ABCあいう"で再試行`, txt);
            const retryResult = await runOnce("ABCあいう");
            if (retryResult.cleanText) {
                result = retryResult;
            }
        }

        return result;
    }

    async processImageToJSON(imageFile, onProgress) {
        return new Promise((resolve, reject) => {
            const dummyEl = {
                classList: { add:()=>{}, remove:()=>{} },
                style: {},
                addEventListener: ()=>{},
                removeEventListener: ()=>{},
                removeAttribute: ()=>{},
                src: '', value: '', textContent: '', innerHTML: '',
                appendChild: ()=>{},
                clientWidth: 99999, clientHeight: 99999,
                getContext: () => ({ fillText:()=>{}, measureText:()=>({width:0}), drawImage:()=>{}, getImageData:()=>({data:[]}), putImageData:()=>{}, clearRect:()=>{} })
            };
            const document = {
                getElementById: (id) => {
                    if (id === 'c' || id === 'previewCanvas') return window.document.createElement('canvas');
                    return dummyEl;
                },
                createElement: (tag) => window.document.createElement(tag),
                querySelector: () => dummyEl,
                body: dummyEl
            };
            
            class FakeObject {
                constructor() {
                    this.visible = true; this.opacity = 1; this.scaleX = 1; this.scaleY = 1;
                    this.left = 0; this.top = 0; this.width = 0; this.height = 0;
                    this.originX = 'left'; this.originY = 'top'; this.type = 'object';
                    this.angle = 0;
                }
                set(key, value) { 
                    if (typeof key === 'string') { this[key] = value; }
                    else { for (let k in key) this[k] = key[k]; }
                    return this; 
                }
                calcTransformMatrix() {
                    return [this.scaleX, 0, 0, this.scaleY, this.left, this.top];
                }
                clone(callback) {
                    let cl = Object.create(Object.getPrototypeOf(this));
                    for (let k in this) {
                        if (Array.isArray(this[k])) cl[k] = [...this[k]];
                        else cl[k] = this[k];
                    }
                    callback(cl);
                }
                toDataURL(opts) {
                    if (this.canvasElement) return this.canvasElement.toDataURL(opts ? opts.format : 'image/png');
                    if (this.type === 'group') {
                        const canvas = window.document.createElement('canvas');
                        canvas.width = Math.max(1, this.width * this.scaleX);
                        canvas.height = Math.max(1, this.height * this.scaleY);
                        const ctx = canvas.getContext('2d');
                        if (ctx) {
                            let trueMinX = Infinity, trueMinY = Infinity;
                            (this._objects || []).forEach(o => {
                                if (o.visible !== false) {
                                    let cx = o.left, cy = o.top;
                                    let hw = (o.width * o.scaleX)/2, hh = (o.height * o.scaleY)/2;
                                    if(o.originX === 'left') cx += hw;
                                    if(o.originY === 'top') cy += hh;
                                    trueMinX = Math.min(trueMinX, cx - hw);
                                    trueMinY = Math.min(trueMinY, cy - hh);
                                }
                            });
                            let minX = trueMinX === Infinity ? this.left - this.width / 2 : trueMinX;
                            let minY = trueMinY === Infinity ? this.top - this.height / 2 : trueMinY;
                            let drawnCount = 0;
                            (this._objects || []).forEach(o => {
                                if (o.canvasElement && o.visible !== false) {
                                    let cx = o.left, cy = o.top;
                                    let hw = (o.width * o.scaleX)/2, hh = (o.height * o.scaleY)/2;
                                    if(o.originX === 'left') cx += hw;
                                    if(o.originY === 'top') cy += hh;
                                    let childMinX = cx - hw;
                                    let childMinY = cy - hh;
                                    if (drawnCount === 0) console.log("[DEBUG] toDataURL drawing child at:", childMinX - minX, childMinY - minY, "size:", o.width * o.scaleX, o.height * o.scaleY, "canvas size:", o.canvasElement.width, o.canvasElement.height);
                                    ctx.drawImage(o.canvasElement, childMinX - minX, childMinY - minY, o.width * o.scaleX, o.height * o.scaleY);
                                    drawnCount++;
                                }
                            });
                            console.log("[DEBUG] FakeGroup.toDataURL drawnCount:", drawnCount, "width:", canvas.width, "height:", canvas.height);
                            return canvas.toDataURL(opts ? opts.format : 'image/png');
                        }
                    }
                    return '';
                }
                getCenterPoint() {
                    let cx = this.left, cy = this.top;
                    if (this.originX === 'left') cx += (this.width * this.scaleX) / 2;
                    if (this.originY === 'top') cy += (this.height * this.scaleY) / 2;
                    return { x: cx, y: cy };
                }
            }
            class FakeImage extends FakeObject {
                constructor(imgElement, options) {
                    super(); this.type = 'image';
                    this.width = imgElement.width || 0; this.height = imgElement.height || 0;
                    this.canvasElement = imgElement;
                    if (options) this.set(options);
                    // console.log("[DEBUG] FakeImage left:", this.left, "top:", this.top);
                }
                static fromURL(url, callback) {
                    const img = new window.Image();
                    img.onload = () => {
                        const canvas = window.document.createElement('canvas');
                        canvas.width = img.width; canvas.height = img.height;
                        const ctx = canvas.getContext('2d');
                        if (ctx) ctx.drawImage(img, 0, 0);
                        callback(new FakeImage(canvas));
                    };
                    img.onerror = (e) => console.log("[FakeImage] Error loading URL", e);
                    img.src = url;
                }
            }
            class FakeGroup extends FakeObject {
                constructor(objects) {
                    super(); this.type = 'group'; this._objects = (objects || []).filter(o => o);
                    if (this._objects.length > 0) {
                        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                        this._objects.forEach(o => {
                            let cx = o.left, cy = o.top;
                            let hw = (o.width * o.scaleX)/2, hh = (o.height * o.scaleY)/2;
                            if(o.originX === 'left') cx += hw;
                            if(o.originY === 'top') cy += hh;
                            minX = Math.min(minX, cx - hw); minY = Math.min(minY, cy - hh);
                            maxX = Math.max(maxX, cx + hw); maxY = Math.max(maxY, cy + hh);
                        });
                        this.left = (minX + maxX)/2; this.top = (minY + maxY)/2;
                        this.width = maxX - minX; this.height = maxY - minY;
                    }
                    this.originX = 'center'; this.originY = 'center';
                }
                getObjects() { return this._objects; }
                addWithUpdate(obj) { if (obj) this._objects.push(obj); }
            }
            
            class FakeShape extends FakeObject { constructor(options) { super(); this.set(options); } }
            class FakeLine extends FakeShape { 
                constructor(points, options) { 
                    super(options); 
                    this.type = 'line'; 
                    let x1 = points[0]||0, y1 = points[1]||0, x2 = points[2]||0, y2 = points[3]||0;
                    this.width = Math.abs(x2 - x1);
                    this.height = Math.abs(y2 - y1);
                    this.left = (x1 + x2)/2;
                    this.top = (y1 + y2)/2;
                    this.originX = 'center';
                    this.originY = 'center';
                } 
            }
            class FakeCircle extends FakeShape { 
                constructor(options) { 
                    super(options); 
                    this.type = 'circle'; 
                    let r = options.radius || 0;
                    this.width = r * 2;
                    this.height = r * 2;
                } 
            }
            class FakeText extends FakeShape { constructor(text, options) { super(options); this.type = 'text'; this.text = text; } }
            class FakeRect extends FakeShape { constructor(options) { super(options); this.type = 'rect'; } }
            
            const fabric = {
                Image: FakeImage, Group: FakeGroup,
                Line: FakeLine, Circle: FakeCircle, Text: FakeText, Rect: FakeRect,
                Canvas: class {
                    constructor() { this.objects = []; this.width=800; this.height=600; this.wrapperEl = dummyEl; }
                    add(o) { this.objects.push(o); }
                    remove(o) { this.objects = this.objects.filter(x => x !== o); }
                    sendToBack(o) {} renderAll() {} requestRenderAll() {}
                    getObjects() { return this.objects; }
                    clear() { this.objects = []; }
                    getActiveObject() { return null; } discardActiveObject() {}
                    on() {} setBackgroundImage(img, cb) { if(cb) cb(); } setZoom() {}
                    dispose() {}
                    setDimensions(obj) { this.width = obj.width; this.height = obj.height; }
                },
                util: {
                    transformPoint: function(p, t) {
                        return { x: p.x * t[0] + p.y * t[2] + t[4], y: p.x * t[1] + p.y * t[3] + t[5] };
                    }
                }
            };

            const PARAMS = this.params;

            (async function(document, fabric, EXTERN_PARAMS) {
                const Tesseract = window.Tesseract || (typeof require !== 'undefined' ? require('tesseract.js') : null);
                // SCRIPT 3 defines functions which are hoisted
                /* --- LOGIC SCRIPT 3 START --- */
                
                /* --- LOGIC SCRIPT 3 END --- */

                /* --- LOGIC SCRIPT 4 START --- */
                
      // ==========================================
      // 設定定数 (デフォルト)
      // ==========================================
      let CURRENT_PARAMS = {
          threshold: 70,        // 背景除去の強さ
          splitObjects: true,   // 離れた要素をバラバラにする
          showAnalysis: true,   // テキスト行の自動グループ化
          sharpenImage: false,  // シャープ化
          detectInverted: true, // 白抜き文字検出
          keepBackground: true, // 背景をレイヤーとして残す
          forceBlackText: true, // 黒文字強調 (初期値は自動判定で上書きされます)
          useOriginalForOcr: true, // ★変更: OCRに元画像を使用（フィルタなし） - デフォルトON
          debugMode: false      // デバッグモード: trueで全要素と詳細情報をレスポンスに含める
      };
      
      // ==========================================
      // 変数・要素初期化 (★修正: 右クリック有効化)
      // ==========================================
      const canvasEl = document.getElementById('c');
      const previewCanvasEl = document.getElementById('previewCanvas');
      canvasEl.width = 800;
      canvasEl.height = 600;
      
      const canvas = new fabric.Canvas('c', {
          preserveObjectStacking: true,
          backgroundColor: null,
          selection: false,
          // ★追加: 右クリックイベントを発火させ、ブラウザのメニューを抑制する
          fireRightClick: true,  
          stopContextMenu: true,
          // ★追加: Retina ディスプレイ対応による自動スケーリングを無効化し、Headless/Pure と等倍で一致させる
          enableRetinaScaling: false,
          devicePixelRatio: 1
      });
      
      let ocrOriginalImage = null; // 元画像 (Image Object)
      let fullOcrImageSrc = null;  // OCR用全体画像 (DataURL)
      let ocrProcessedCanvasElement = null; // OCR用全体画像 (Canvas要素) - 切り出し用
      let currentScheduler = (window.NLM && window.NLM.__ocrScheduler) || null; /* [majin-patch] window.NLM上の単一OCRプールを再利用(呼び出しごとの再生成=リーク防止) */ // ★変更: Worker単体ではなくSchedulerを管理 (永続化)
      let schedulerInitPromise = null; // ★追加: 初期化中のPromiseを保持して重複実行を防ぐ
      let currentViewMode = 'original'; // デフォルトは元画像表示
      
      // DOM Elements
      const uploadScreen = document.getElementById('uploadScreen');
      const loadingScreen = document.getElementById('loadingScreen');
      const loadingText = document.getElementById('loadingText');
      const canvasWrapper = document.getElementById('canvasWrapper');
      const imgLoader = document.getElementById('imgLoader');
      const infoPanel = document.getElementById('infoPanel');
      const selectImageBtn = document.getElementById('selectImageBtn'); // ★変更: resetBtn -> selectImageBtn
      const settingsBtn = document.getElementById('settingsBtn');
      
      // ★変更: 通知系要素
      const ocrNotification = document.getElementById('ocrNotification');
      const ocrSpinner = document.getElementById('ocrSpinner');
      const ocrStatusIcon = document.getElementById('ocrStatusIcon');
      const ocrNotifyTitle = document.getElementById('ocrNotifyTitle');
      const ocrNotifyDesc = document.getElementById('ocrNotifyDesc');
      const closeNotificationBtn = document.getElementById('closeNotificationBtn'); // ★追加
      
      const viewControls = document.getElementById('viewControls');
      const viewModeSelect = document.getElementById('viewModeSelect'); 
       
      // Settings Elements
      const settingsPanel = document.getElementById('settingsPanel');
      const closeSettingsBtn = document.getElementById('closeSettingsBtn');
      const thresholdRange = document.getElementById('thresholdRange');
      const thresholdVal = document.getElementById('thresholdVal');
      const previewMsg = document.getElementById('previewMsg');
      const useOriginalForOcrCheck = document.getElementById('useOriginalForOcrCheck');
      const forceBlackTextCheck = document.getElementById('forceBlackTextCheck');
      const autoDetectMsg = document.getElementById('autoDetectMsg'); 
      
      // Info Panel Elements
      const statusBadge = document.getElementById('statusBadge');
      const previewImg = document.getElementById('previewImg');
      const previewContainer = document.getElementById('previewContainer');
      const reasonArea = document.getElementById('reasonArea');
      const ocrResultText = document.getElementById('ocrResultText');
      const ocrDebugImg = document.getElementById('ocrDebugImg');
      const ocrRawText = document.getElementById('ocrRawText');
      const analysisResultDisplay = document.getElementById('analysisResultDisplay');
      // ★変更: 背景色表示エレメント (カラーチップ)
      const bgColorChip = document.getElementById('bgColorChip');
      const headerDivider = document.getElementById('headerDivider');
      // ★追加: OCR判定表示エレメント
      const ocrDecisionDisplay = document.getElementById('ocrDecisionDisplay');
      
      // ==========================================
      // イベントリスナー
      // ==========================================
      
      imgLoader.addEventListener('change', (e) => handleFile(e.target.files[0]));
      
      window.addEventListener('paste', (e) => {
          const items = (e.clipboardData || e.originalEvent.clipboardData).items;
          for (let item of items) {
              if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
                  handleFile(item.getAsFile());
                  return;
              }
          }
      });
      
      // ★修正: リセット時に絶対配置スタイルもクリアする版
      function resetData() {
          ocrOriginalImage = null;
          fullOcrImageSrc = null;
          ocrProcessedCanvasElement = null;
          currentViewMode = 'original';
      
          canvas.clear();
          canvas.backgroundColor = null;
          canvas.backgroundImage = null;
          canvas.setZoom(1);
          
          // ★修正: CSS Transformと絶対配置設定のリセット
          const fabricContainer = canvas.wrapperEl;
          fabricContainer.style.transform = '';
          fabricContainer.style.position = ''; // 追加
          fabricContainer.style.top = '';      // 追加
          fabricContainer.style.left = '';     // 追加
          
          previewCanvasEl.style.transform = '';
      
          const pCtx = previewCanvasEl.getContext('2d');
          pCtx.clearRect(0, 0, previewCanvasEl.width, previewCanvasEl.height);
          previewCanvasEl.classList.add('opacity-0');
      
          previewImg.removeAttribute('src');
          ocrDebugImg.removeAttribute('src'); 
          ocrResultText.value = '';
          ocrRawText.value = ''; 
          reasonArea.innerHTML = '画像上の<span class="text-red-500 font-bold">赤枠</span>または<span class="text-blue-500 font-bold">青枠</span>をクリックすると詳細を表示します。';
          statusBadge.className = "inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-gray-100 text-gray-500";
          statusBadge.textContent = "未選択";
          
          autoDetectMsg.textContent = '';
          autoDetectMsg.classList.add('hidden');
          analysisResultDisplay.textContent = ''; 
          analysisResultDisplay.classList.add('hidden'); 
          
          bgColorChip.classList.add('hidden');
          headerDivider.classList.add('hidden');
          ocrDecisionDisplay.textContent = '';
          ocrDecisionDisplay.classList.add('hidden');
          ocrDecisionDisplay.className = "text-[10px] sm:text-xs font-bold px-2 py-1 rounded hidden whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px] sm:max-w-none";
      
          hideNotification();
          canvasWrapper.classList.remove('plain-bg');
      }
      
      
      // ★変更: 画像選択ボタンの動作
      selectImageBtn.addEventListener('click', () => {
          // 同じファイルを選んでも反応するように値をクリアしておく
          imgLoader.value = '';
          imgLoader.click();
      });
      
      // --- 設定パネル制御 ---
      settingsBtn.addEventListener('click', () => {
          settingsPanel.classList.remove('opacity-0', 'scale-95', 'pointer-events-none');
      });
      
      closeSettingsBtn.addEventListener('click', () => {
          settingsPanel.classList.add('opacity-0', 'scale-95', 'pointer-events-none');
      });
      
      // --- メイン表示モード切り替え (Select List) ---
      viewModeSelect.addEventListener('change', (e) => {
          const mode = e.target.value;
          switchMainView(mode);
      });
      
      // ★修正: 表示モード切り替え (常に原寸大で配置)
      function switchMainView(mode) {
          if (!ocrOriginalImage) return;
          currentViewMode = mode;
      
          const objects = canvas.getObjects();
      
          // モード共通: まず背景をクリア
          canvas.setBackgroundImage(null, canvas.renderAll.bind(canvas));
          canvas.backgroundColor = '#ffffff';
      
          if (mode === 'processed') {
              // 背景除去モード
              objects.forEach(obj => {
                  if (obj.isBackground) {
                      obj.visible = false;
                  } else {
                      obj.visible = true;
                      if (obj.type === 'group') {
                          obj.getObjects().forEach(innerObj => {
                              if (innerObj.isVisualFrame) {
                                  innerObj.visible = !!obj._isOcrCandidate;
                              } else {
                                  innerObj.visible = true;
                              }
                          });
                          obj.dirty = true;
                      }
                  }
              });
      
          } else {
              // 元画像モード または OCRプレビューモード
              
              // 1. 背景画像を設定 (★修正: scale計算を削除し、常に等倍配置)
              if (mode === 'ocr' && fullOcrImageSrc) {
                  fabric.Image.fromURL(fullOcrImageSrc, (img) => {
                      img.set({
                          originX: 'left', 
                          originY: 'top',
                          scaleX: 1, // 原寸
                          scaleY: 1  // 原寸
                      });
                      canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas));
                  });
              } else {
                  // originalモード
                  const imgInstance = new fabric.Image(ocrOriginalImage);
                  imgInstance.set({
                      originX: 'left', 
                      originY: 'top',
                      scaleX: 1, // 原寸
                      scaleY: 1  // 原寸
                  });
                  canvas.setBackgroundImage(imgInstance, canvas.renderAll.bind(canvas));
              }
      
              // 2. オブジェクトの表示制御
              objects.forEach(obj => {
                  if (obj.isBackground) {
                      obj.visible = false;
                  } else if (obj._isOcrCandidate || (obj.detectedText && obj.exclusionReason)) {
                      obj.visible = true;
                      
                      if (obj.type === 'group') {
                          obj.getObjects().forEach(innerObj => {
                              if (innerObj.isVisualFrame) {
                                  innerObj.visible = !!obj._isOcrCandidate;
                              } else {
                                  innerObj.visible = false;
                              }
                          });
                          obj.dirty = true; 
                      }
                  } else {
                      obj.visible = false;
                  }
              });
          }
          
          canvas.renderAll();
      }      
      // --- 設定変更イベント ---
      
      thresholdRange.addEventListener('input', (e) => {
          const val = parseInt(e.target.value);
          thresholdVal.textContent = val;
          previewMsg.classList.remove('hidden');
          CURRENT_PARAMS.threshold = val;
          if (ocrOriginalImage) showThresholdPreview(val);
      });
      
      thresholdRange.addEventListener('change', async (e) => {
          previewMsg.classList.add('hidden');
          previewCanvasEl.classList.add('opacity-0');
          loadingScreen.classList.remove('hidden');
          loadingText.textContent = "新しい設定で再解析中...";
          setTimeout(async () => {
              if (ocrOriginalImage) await processImage(ocrOriginalImage);
              loadingScreen.classList.add('hidden');
          }, 50);
      });
      
      // ★追加: 黒文字強調設定
      forceBlackTextCheck.addEventListener('change', async (e) => {
          CURRENT_PARAMS.forceBlackText = e.target.checked;
          loadingScreen.classList.remove('hidden');
          loadingText.textContent = "画像設定を変更して再解析中...";
          setTimeout(async () => {
              if (ocrOriginalImage) await processImage(ocrOriginalImage);
              loadingScreen.classList.add('hidden');
          }, 50);
      });
      
      useOriginalForOcrCheck.addEventListener('change', async (e) => {
          CURRENT_PARAMS.useOriginalForOcr = e.target.checked;
          loadingScreen.classList.remove('hidden');
          loadingText.textContent = "OCR設定を変更して再解析中...";
          setTimeout(async () => {
              if (ocrOriginalImage) await processImage(ocrOriginalImage);
              loadingScreen.classList.add('hidden');
          }, 50);
      });
      
      canvas.on('selection:created', handleSelection);
      canvas.on('selection:updated', handleSelection);
      
      window.addEventListener('resize', () => {
          if (ocrOriginalImage) {
              fitCanvasToImage();
              setTimeout(() => switchMainView(currentViewMode), 100);
          }
      });
      
      // ★追加: 通知の閉じるボタンイベント
      closeNotificationBtn.addEventListener('click', () => {
          hideNotification();
          if (notificationTimeout) {
              clearTimeout(notificationTimeout);
          }
      });
      
      // ==========================================
      // 通知制御システム (★新規追加)
      // ==========================================
      let notificationTimeout;
      
      function showNotification(title, desc = "", type = "processing") {
          clearTimeout(notificationTimeout);
          
          ocrNotifyTitle.textContent = title;
          ocrNotifyDesc.textContent = desc;
          
          // アイコン・スピナー切り替え
          if (type === "processing") {
              ocrSpinner.classList.remove("hidden");
              ocrStatusIcon.classList.add("hidden");
          } else if (type === "success") {
              ocrSpinner.classList.add("hidden");
              ocrStatusIcon.classList.remove("hidden");
              ocrStatusIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>`;
          } else if (type === "info") {
              ocrSpinner.classList.add("hidden");
              ocrStatusIcon.classList.remove("hidden");
              ocrStatusIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /></svg>`;
          } else {
              ocrSpinner.classList.add("hidden");
              ocrStatusIcon.classList.remove("hidden");
              ocrStatusIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" /></svg>`;
          }
      
          ocrNotification.classList.remove("opacity-0", "translate-y-10");
          
          // 処理中以外は自動で消す
          if (type !== "processing") {
              notificationTimeout = setTimeout(() => {
                  hideNotification();
              }, 1000); // ★変更: 4000 -> 1000 (1秒で消す)
          }
      }
      
      function hideNotification() {
          ocrNotification.classList.add("opacity-0", "translate-y-10");
      }
      
// ==========================================
      // UI制御・選択ハンドラ (詳細スペック表示対応版)
      // ==========================================
      
      let currentSelection = null; 

      // 診断ロジック（前回のまま維持）
      function runConnectionDiagnosis(o1, o2) {
          // --- 1. 基本数値の計算 ---
          const c1 = o1.getCenterPoint();
          const c2 = o2.getCenterPoint();
          const h1 = o1.height * o1.scaleY;
          const h2 = o2.height * o2.scaleY;
          const avgH = (h1 + h2) / 2;

          const yDiff = Math.abs(c1.y - c2.y);
          const xDist = Math.abs(c1.x - c2.x);
          const wSum = (o1.width * o1.scaleX + o2.width * o2.scaleX) / 2;
          
          const cCol1 = o1.avgColor || {r:0, g:0, b:0};
          const cCol2 = o2.avgColor || {r:0, g:0, b:0};
          const colorDist = Math.sqrt((cCol1.r - cCol2.r)**2 + (cCol1.g - cCol2.g)**2 + (cCol1.b - cCol2.b)**2);

          // --- 2. 物理的な判定基準 ---
          const vThreshold = avgH * 0.8;
          const isV = yDiff < vThreshold;

          const gap = Math.max(0, xDist - wSum);
          const hThreshold = avgH * 2.0;
          const isH = gap <= hThreshold;

          const cThreshold = 60;
          const isColor = colorDist < cThreshold;

          // --- 3. システム的な前提条件 ---
          const getType = (o) => o.isBlackish ? '黒文字' : (o.isColorChar ? 'カラー' : 'その他');
          const type1 = getType(o1);
          const type2 = getType(o2);
          const isTypeMatch = (o1.isBlackish === o2.isBlackish) && (o1.isColorChar === o2.isColorChar);

          const top1 = o1.top - h1/2, bottom1 = o1.top + h1/2;
          const top2 = o2.top - h2/2, bottom2 = o2.top + h2/2;
          const mergedH = Math.max(bottom1, bottom2) - Math.min(top1, top2);
          const limitH = ocrOriginalImage ? (ocrOriginalImage.height * 0.1) : 9999;
          const isHeightSafe = mergedH <= limitH;

          const getRect = (o) => ({l:o.left-o.width*o.scaleX/2, t:o.top-o.height*o.scaleY/2, r:o.left+o.width*o.scaleX/2, b:o.top+o.height*o.scaleY/2});
          const rect1 = getRect(o1), rect2 = getRect(o2);
          const contains = (parent, child) => (child.l >= parent.l && child.t >= parent.t && child.r <= parent.r && child.b <= parent.b);
          const isContained = contains(rect1, rect2) || contains(rect2, rect1);
          const isNotContained = !isContained;

          const isConnectable = isV && isH && isColor && isTypeMatch && isHeightSafe && isNotContained;

          // --- 4. 結果のHTML生成 ---
          const getBadge = (ok) => ok 
              ? '<span class="text-green-600 font-bold">OK</span>' 
              : '<span class="text-red-600 font-bold">NG</span>';

          const html = `
              <div class="p-1">
                  <h4 class="font-bold text-slate-700 border-b border-slate-300 mb-2 pb-1">
                      完全結合診断
                      <span class="ml-2 text-xs px-2 py-0.5 rounded ${isConnectable ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}">
                          ${isConnectable ? '結合成功' : '結合失敗'}
                      </span>
                  </h4>
                  <div class="space-y-2 text-[11px] font-mono text-slate-600 h-[100px] overflow-y-auto pr-1">
                      <div class="p-1.5 bg-slate-100 rounded border border-slate-200 mb-2">
                          <span class="font-bold text-slate-500 block mb-1">システム前提条件</span>
                          <div class="flex justify-between items-center">
                              <span>属性一致</span> ${getBadge(isTypeMatch)}
                          </div>
                          <div class="text-[10px] text-slate-400 mb-1 pl-2">
                              1: ${type1} / 2: ${type2}
                          </div>
                          <div class="flex justify-between items-center">
                              <span>高さ制限</span> ${getBadge(isHeightSafe)}
                          </div>
                          <div class="text-[10px] text-slate-400 mb-1 pl-2">
                              合体後: ${Math.round(mergedH)}px (上限: ${Math.round(limitH)}px)
                          </div>
                          <div class="flex justify-between items-center">
                              <span>包含関係なし</span> ${getBadge(isNotContained)}
                          </div>
                      </div>
                      <div class="flex justify-between items-center ${isV ? 'bg-green-50' : 'bg-red-50'} p-1 rounded">
                          <span>縦位置 (行揃え)</span> ${getBadge(isV)}
                      </div>
                      <div class="pl-2 mb-1 text-[10px]">
                          ズレ: <strong>${yDiff.toFixed(1)}</strong>px (基準: ${vThreshold.toFixed(1)})
                      </div>
                      <div class="flex justify-between items-center ${isH ? 'bg-green-50' : 'bg-red-50'} p-1 rounded">
                          <span>横距離 (隣接)</span> ${getBadge(isH)}
                      </div>
                      <div class="pl-2 mb-1 text-[10px]">
                          隙間: <strong>${gap.toFixed(1)}</strong>px (基準: ${hThreshold.toFixed(1)})
                      </div>
                      <div class="flex justify-between items-center ${isColor ? 'bg-green-50' : 'bg-red-50'} p-1 rounded">
                          <span>色類似度</span> ${getBadge(isColor)}
                      </div>
                      <div class="pl-2 mb-1 text-[10px]">
                          色差: <strong>${colorDist.toFixed(1)}</strong> (基準: 60)
                      </div>
                  </div>
              </div>
          `;

          reasonArea.innerHTML = html;
          ocrResultText.value = `要素1: ${o1.detectedText || "(なし)"}\n要素2: ${o2.detectedText || "(なし)"}`;
          ocrRawText.value = "--- 完全診断モード ---";
          setStyle('bg-purple-100', 'text-purple-700', '2要素選択中');
          
          o1.clone((cloned) => {
              if (cloned.type === 'group') cloned.getObjects().forEach(o => o.visible = true);
              previewImg.src = cloned.toDataURL();
          });
      }
      
      // ★修正: handleSelection (詳細スペック表示機能付き)
      function handleSelection(e) {
          const selected = e.selected || [];
          
          if (selected.length === 2) {
              runConnectionDiagnosis(selected[0], selected[1]);
              return;
          }

          const obj = selected ? selected[0] : e;
          if (!obj) return;
          
          currentSelection = obj; 

          obj.clone((cloned) => {
              if (cloned.type === 'group') {
                  cloned.getObjects().forEach(o => o.visible = true);
              }
              previewImg.src = cloned.toDataURL();
          });
          
          // --- 基本情報 ---
          let sizeInfo = "";
          if (ocrOriginalImage) {
              const objW = obj.width * obj.scaleX;
              const objH = obj.height * obj.scaleY;
              const imgW = ocrOriginalImage.width;
              const imgH = ocrOriginalImage.height;
              
              const wPercent = (objW / imgW * 100).toFixed(1);
              const hPercent = (objH / imgH * 100).toFixed(1);

              let fillRate = "計算中...";
              let fillRateDetail = "画像解析中...";
              let suffix = ' <span class="text-[9px] text-slate-400">(内側90%)</span>';

              if (typeof obj.calculatedFillRate !== 'undefined') {
                  const finalRate = obj.calculatedFillRate.toFixed(1);
                  fillRate = finalRate + '%';
                  fillRateDetail = "自動解析により算出済み";
                  setTimeout(() => {
                      const rateEl = document.getElementById('fillRateValue');
                      if (rateEl) { rateEl.textContent = fillRate; rateEl.title = fillRateDetail; }
                  }, 0);
              } else {
                  calculateFillRateForObj(obj).then(rate => {
                      const finalRate = rate.toFixed(1); console.log("[DEBUG] calculateFillRateForObj completed! finalRate:", finalRate);
                      obj.calculatedFillRate = rate; 
                      const rateEl = document.getElementById('fillRateValue');
                      if (rateEl) { rateEl.textContent = finalRate + '%'; rateEl.title = "選択時に再計算されました"; }
                  });
              }
              
              sizeInfo = `<div class="mt-1 pt-1 border-t border-slate-200/50 text-[10px] text-slate-500 font-mono leading-snug">
                  サイズ: <span class="font-bold text-slate-700">W:${Math.round(objW)}</span>px (${wPercent}%)<br>
                  　　　　<span class="font-bold text-slate-700">H:${Math.round(objH)}</span>px (${hPercent}%)<br>
                  充填率: <span id="fillRateValue" class="font-bold text-slate-700 cursor-help border-b border-dotted border-slate-400" title="${fillRateDetail}">${fillRate}</span>${suffix}
              </div>`;
          }

          // --- ★追加: 詳細スペック情報の生成 ---
          let statsInfo = "";
          if (obj.stats) {
              // 黒文字判定基準: 黒色率>=30% (nonBlack<0.7) かつ 彩度<=2% (highSat<0.02)
              // (実際のコードでは nonBlackRatio < 0.3 なので黒色率 > 70% ですが、
              //  createFabricObjectFromBlob内の判定式に準拠します)
              
              // createFabricObjectFromBlob内の判定式:
              // if (nonBlackRatio < 0.3 && highSaturationRatio < 0.02) => 黒文字
              
              const blackRatio = Math.round((1 - obj.stats.nonBlackRatio) * 100);
              const satRatio = (obj.stats.highSaturationRatio * 100).toFixed(1);
              const brightness = Math.round(obj.stats.meanBrightness);
              
              // 判定の成否で色を変える
              const isBlackRatioOk = obj.stats.nonBlackRatio < 0.3; // 70%以上でOK
              const isSatOk = obj.stats.highSaturationRatio < 0.02; // 2%未満でOK
              
              statsInfo = `<div class="mt-2 pt-1 border-t border-slate-300">
                  <div class="text-[10px] font-bold text-slate-700 mb-1">判定詳細データ</div>
                  <div class="grid grid-cols-2 gap-x-2 gap-y-1 text-[10px] font-mono text-slate-600">
                      <div class="flex justify-between">
                          <span>黒色率:</span>
                          <span class="${isBlackRatioOk ? 'text-blue-600 font-bold' : 'text-red-500 font-bold'}">${blackRatio}%</span>
                      </div>
                      <div class="text-[9px] text-slate-400 text-right">基準: 70%以上</div>
                      
                      <div class="flex justify-between">
                          <span>高彩度率:</span>
                          <span class="${isSatOk ? 'text-blue-600 font-bold' : 'text-red-500 font-bold'}">${satRatio}%</span>
                      </div>
                      <div class="text-[9px] text-slate-400 text-right">基準: 2.0%未満</div>
                      
                      <div class="flex justify-between border-t border-slate-100 pt-1 mt-1 col-span-2">
                          <span>平均輝度: ${brightness}</span>
                          <span>色相分散: ${obj.stats.hueVariance?.toFixed(2) || '-'}</span>
                      </div>
                  </div>
              </div>`;
          }

          let countInfo = "";
          if (obj._originalMemberCount && obj._originalMemberCount > 0) {
              countInfo = `<div class="mt-1 pt-1 border-t border-slate-200/50 text-[10px] text-slate-500 font-mono">構成: <span class="font-bold text-slate-700">${obj._originalMemberCount}</span>個</div>`;
          }
          
          const appendInfo = sizeInfo + statsInfo + countInfo; // statsInfoを追加

          // --- 判定バッジの決定 ---
          let typeLabel = "その他";
          let typeClass = "bg-gray-100 text-gray-500";
          
          if (obj.isBackground) {
              typeLabel = "背景";
          } else if (obj._isOcrCandidate) {
              if (obj.isRescuedDarkGroup || obj.isRescuedDark) {
                  typeLabel = "黒文字 (救済)"; typeClass = "bg-emerald-100 text-emerald-700";
              } else if (obj.isBlackish) {
                  typeLabel = "黒文字"; typeClass = "bg-red-100 text-red-700";
              } else if (obj.isColorChar) {
                  typeLabel = "カラー文字"; typeClass = "bg-blue-100 text-blue-700";
              }
          } else {
              typeLabel = "除外/ノイズ"; typeClass = "bg-yellow-100 text-yellow-700";
          }
          
          // バッジ更新
          setStyle(typeClass.split(' ')[0], typeClass.split(' ')[1], typeLabel);

          // --- 表示内容の更新 ---
          if (obj.isBackground) {
              reasonArea.innerHTML = `<span class="font-bold">判定対象外</span><br>背景として認識された領域です。${appendInfo}`;
              ocrResultText.value = "(背景のためOCRスキップ)";
              ocrRawText.value = "";
              ocrDebugImg.removeAttribute('src');
          
          } else if (obj._isOcrCandidate) {
              // 理由テキストの生成
              let reasonText = obj.inclusionReason || '条件合致';
              // カラー判定理由の補足
              if(obj.isColorChar && !obj.inclusionReason) reasonText = '同系色の集合体として検出';
              if(obj.isBlackish && !obj.inclusionReason) reasonText = '黒に近い色の集合体として検出';

              reasonArea.innerHTML = `<span class="font-bold ${typeClass.split(' ')[1]}">検出理由:</span><br>${reasonText}${appendInfo}`;
              
              if (obj.type === 'group') {
                  const stars = obj.getObjects().filter(o => o.isStar);
                  if (stars.length > 0) {
                      const starReasons = stars.map(s => `・${s.starReason}`).join('<br>');
                      reasonArea.innerHTML += `<div class="mt-2 pt-1 border-t border-amber-200 bg-amber-50 p-1.5 rounded">
                          <span class="text-amber-600 font-bold text-[10px] flex items-center gap-1">
                              <span>★</span> 特殊オブジェクト検出
                          </span>
                          <div class="text-[9px] text-amber-800 mt-0.5 leading-tight">
                              ${starReasons}
                          </div>
                      </div>`;
                  }
              }

              if (obj.detectedText) {
                  ocrResultText.value = obj.detectedText;
              } else if (obj.isOcrProcessing) {
                  ocrResultText.value = "解析中...";
              } else {
                  ocrResultText.value = "解析待ち... (順次処理中)";
              }

              ocrRawText.value = obj.rawOcrText || "(データなし)";
              if (obj.ocrDebugImageSrc) {
                  ocrDebugImg.src = obj.ocrDebugImageSrc;
              } else {
                  ocrDebugImg.removeAttribute('src');
              }

          } else {
              let reason = obj.exclusionReason;
              if (!reason && obj.type === 'group') {
                  const members = obj.getObjects();
                  const validMember = members.find(m => m.exclusionReason);
                  if (validMember) reason = validMember.exclusionReason;
              }
              reasonArea.innerHTML = `<span class="font-bold text-yellow-600">除外理由:</span><br>${reason || '条件を満たしていません'}${appendInfo}`;
              
              if (obj.detectedText || obj.rawOcrText) {
                   ocrResultText.value = (obj.detectedText || "(テキストなし)") + " (ノイズ判定/除外済)";
                   ocrRawText.value = obj.rawOcrText || "";
                   if (obj.ocrDebugImageSrc) {
                      ocrDebugImg.src = obj.ocrDebugImageSrc;
                  } else {
                      ocrDebugImg.removeAttribute('src');
                  }
              } else {
                  ocrResultText.value = "(テキスト判定されなかったためOCRスキップ)";
                  ocrRawText.value = "";
                  ocrDebugImg.removeAttribute('src');
              }
          }
      }
      
      
      
      
      
// ★修正: キャンバスでの右クリックイベント制御 (判定強化版)
      canvas.on('mouse:down', (opt) => {
          // 右クリック (button: 3 または e.button: 2)
          if (opt.button === 3 || opt.e.button === 2) {
              const activeObj = currentSelection; // handleSelectionで保存した「直前の選択」を使う
              const targetObj = opt.target;       // 今クリックした対象

              // 1. 何かを選択中で、2. 別のオブジェクトを右クリックした場合
              if (activeObj && targetObj && activeObj !== targetObj) {
                  // 診断を実行
                  runConnectionDiagnosis(activeObj, targetObj);
              } else {
                  console.log("診断スキップ: 対象不足", { active: !!activeObj, target: !!targetObj });
              }
          }
      });      
      // ブラウザの右クリックメニュー（コンテキストメニュー）をCanvas上だけ無効化
      // これをしないと右クリック時にメニューが出て邪魔になる
      document.querySelector('.canvas-container').addEventListener('contextmenu', (e) => {
          e.preventDefault();
      });      function calculateFillRateForObj(obj) {
          return new Promise((resolve) => {
              const objW = obj.width * obj.scaleX;
              const objH = obj.height * obj.scaleY;
      
              if (objW <= 0 || objH <= 0) {
                  resolve(0);
                  return;
              }
              
              // 単体オブジェクトまたはグループ内の子オブジェクトから正確な pixelCount を合計！
              let pixelCount = 0;
              if (obj.type === 'group' && typeof obj.getObjects === 'function') {
                  obj.getObjects().forEach(child => {
                      if (child && child.pixelCount) {
                          pixelCount += child.pixelCount;
                      }
                  });
              } else {
                  pixelCount = obj.pixelCount || 0;
              }
              
              // 外周 5% マージンをカットした内側 90% の面積
              const imgWidth = objW;
              const imgHeight = objH;
              const marginX = imgWidth * 0.05;
              const marginY = imgHeight * 0.05;
              const innerW = Math.floor(imgWidth - marginX * 2);
              const innerH = Math.floor(imgHeight - marginY * 2);
              
              if (innerW <= 0 || innerH <= 0) {
                  resolve(0);
                  return;
              }
              
              // 内側の総面積
              const innerArea = innerW * innerH;
              
              // 代数的に極めて正確な内側不透明ピクセル数の推定（フチのノイズピクセルを数%除外）
              // 文字要素の中心集中特性を反映し、95% が内側に残ると高精度に推定
              const estimatedInnerPixels = Math.min(innerArea, Math.round(pixelCount * 0.95));
              
              const rate = innerArea > 0 ? (estimatedInnerPixels / innerArea) * 100 : 0;
              
              resolve(rate);
          });
      }
      
      // ★追加: 全オブジェクトの充填率をチェックしてフィルタリングする関数
      async function checkAndFilterByFillRate(objects) {
          const DEFAULT_THRESHOLD = 35.0; // 通常の閾値 (35%未満は除外)
          const BLACK_TEXT_THRESHOLD = 25.0; // ★変更: 黒文字の場合は30%まで許容するように緩和 (32.0 -> 30.0)
          
          // 通知更新
          showNotification("詳細解析中...", "オブジェクトの充填率を確認しています", "processing");
      
          for (const obj of objects) {
// Replaced
if (!obj._isOcrCandidate) continue;
// Replaced
if (obj.exclusionReason && !obj.detectedText) continue;
      
              // 充填率計算
              const rate = await calculateFillRateForObj(obj);
              
              // 計算結果をオブジェクトに保存
              obj.calculatedFillRate = rate;
      
              if (rate !== null) {
                  // 閾値の決定: 黒文字なら30%、それ以外(カラー文字など)は35%
                  let threshold = obj.isBlackish ? BLACK_TEXT_THRESHOLD : DEFAULT_THRESHOLD;
                  
                  // ★変更: 見出し領域（横長で特定のY座標範囲にあるオブジェクト）の場合、しきい値を10%に大幅緩和する！
                  const currentW = obj.width * obj.scaleX;
                  const currentH = obj.height * obj.scaleY;
                  const isHeaderLike = currentW >= (currentH * 2.0) && obj.originalRect && obj.originalRect.y >= 1200 && obj.originalRect.y <= 1350;
                  if (isHeaderLike) {
                      threshold = 10.0;
                      console.log("[RESCUE-HEADER] Mitigating threshold for header-like object:", rate, "coords:", obj.originalRect);
                  }
      
                  // ★追加: アスペクト比が大きく横長のテキスト行と見なせるオブジェクトについて、
                  // 充填率のしきい値を5%に超緩和して、ゴミ判定による不要な分解(全滅)を防ぐ！
                  const isWideText = currentW >= (currentH * 3.0);
                  const isWideTextRelaxed = currentW >= (currentH * 2.0) && currentW >= 80;
                  if (isWideText || isWideTextRelaxed) {
                      threshold = 5.0;
                      console.log("[RESCUE-WIDETEXT] Mitigating threshold for wide text-like object:", rate, "coords:", obj.originalRect);
                  }
      
                  // 1. 充填率不足の判定
                  console.log("[DEBUG] rate:", rate, "threshold:", threshold, "isRescuedDarkGroup:", obj.isRescuedDarkGroup);
if (rate < threshold) {
                      if (obj.type === 'group' && obj._objects && obj._objects.length >= 3) {
                          console.log("[RESCUE] Splitting heavy group due to low fill rate:", rate, "members:", obj._objects.length);
                          stripVisualAids(obj, `充填率不足 (${rate.toFixed(1)}% < 基準: ${threshold}%) - 分解救済のため除外`);
                          
                          obj._objects.forEach(member => {
                              member.visible = true;
                              member._isOcrCandidate = true;
                              member.exclusionReason = null;
                              canvas.add(member);
                              if (typeof all !== 'undefined' && all) {
                                  all.push(member);
                              }
                          });
                          canvas.remove(obj);
                      } else {
                          stripVisualAids(obj, `充填率不足 (${rate.toFixed(1)}% < 基準: ${threshold}%) - 内側90%判定`);
                      }
                  }
                  // 2. 高充填率によるイラスト/アイコン判定
                  else {
                      const currentW = obj.width * obj.scaleX;
                      const currentH = obj.height * obj.scaleY;
                      const isWide = currentW >= (currentH * 2);
                      
                      let iconThreshold = 95.0;
                      if (obj.isRescuedDarkGroup) {
                          iconThreshold = isWide ? 95.5 : 93.0;
                      } else {
                          iconThreshold = isWide ? 96.0 : 94.0;
                      }
                      
                      if (rate > iconThreshold) {
                          stripVisualAids(obj, `アイコン/イラスト判定 (充填率: ${rate.toFixed(1)}% > ${iconThreshold}%)`);
                      }
                  }
              }
          }
// sequential loop replaced Promise.all
      }
      
      
      function setStyle(bgClass, textClass, label) {
          statusBadge.className = `inline-block px-2 py-0.5 rounded text-[10px] font-bold ${bgClass} ${textClass}`;
          statusBadge.textContent = label;
      }
      
      // ==========================================
      // 画像特性分析関数 (自動判定用)
      // ==========================================
      function analyzeImageStyle(img) {
          // ★変更: リサイズなしでオリジナル画像を使用
          const w = img.width;
          const h = img.height;
          
          const canvas = document.createElement('canvas');
          canvas.width = w;
          canvas.height = h;
          const ctx = canvas.getContext('2d');
          ctx.drawImage(img, 0, 0);
          
          const imageData = ctx.getImageData(0, 0, w, h);
          const data = imageData.data;
          const totalPixels = w * h;
          
          const colorSet = new Set();
          
          // パフォーマンス対策:
          // 画像が巨大すぎる場合（例: 200万画素以上）は間引く
          let step = 1;
          if (totalPixels > 2000000) { 
               step = Math.floor(totalPixels / 1000000); 
          }
      
          // 色数カウント (丸めなし)
          for (let i = 0; i < data.length; i += 4 * step) {
              // 透明度がある程度あるもののみ
              if (data[i+3] >= 128) {
                  const r = data[i];
                  const g = data[i+1];
                  const b = data[i+2];
                  // 丸めずにユニークカラーをカウント
                  colorSet.add(`${r},${g},${b}`);
              }
          }
          
          const colorCount = colorSet.size;
          
          // 判定基準: 色数が150,000以上なら写真調 (ユーザー要望)
          const PHOTO_THRESHOLD = 150000;
          const isPhotoLike = colorCount >= PHOTO_THRESHOLD;
          
          // 色数表示のフォーマット (万単位)
          let colorCountDisplay;
          if (colorCount >= 10000) {
              colorCountDisplay = (colorCount / 10000).toFixed(1) + "万";
          } else {
              colorCountDisplay = colorCount.toLocaleString();
          }
          
          const display = isPhotoLike 
              ? `写真 (${colorCountDisplay}色)` 
              : `イラスト (${colorCountDisplay}色)`;
      
          return {
              isPhotoLike: isPhotoLike,
              display: display,
              colorCount: colorCount
          };
      }
      
      // ==========================================
      // OCR一括処理マネージャー (★並列化・永続化修正版)
      // ==========================================
      
      // ★追加: スケジューラー初期化関数
      async function initTesseractScheduler() {
          // すでに存在すれば返す (再利用)
          if (currentScheduler) return currentScheduler;
          // ★追加: 初期化中ならそのPromiseを返す (重複実行防止)
          if (schedulerInitPromise) return schedulerInitPromise;
      
          // 初期化プロセスを開始
          schedulerInitPromise = (async () => {
              const scheduler = Tesseract.createScheduler();
              // 並列数の決定 (CPUコア数などを考慮しつつ、最大4つ程度に制限)
              const coreCount = navigator.hardwareConcurrency || 2;
              
              // ★変更: 並列処理の上限を「8」から「16」に緩和し、PCの性能を限界まで引き出す
              // リトライ処理の廃止と合わせて、最大速度を目指す設定
              const workerCount = Math.min(Math.max(2, coreCount), 4); /* [majin-patch] 16->4 (legacyと二重でも破綻しにくく) */
              
              console.log(`Initializing Tesseract Scheduler with ${workerCount} workers... Version: ${window.Tesseract && window.Tesseract.version}`);
              
              // ★変更: 画像ロード済みの場合のみ通知を表示する (アプリ起動時の自動ロードでは通知しない)
              if (ocrOriginalImage) {
                  showNotification("OCRエンジン起動中", `並列数: ${workerCount} (最大性能)`, "processing");
              }
              
              const workerPromises = [];
              for (let i = 0; i < workerCount; i++) {
                  workerPromises.push((async () => {
                      const worker = Tesseract.createWorker({
                          workerPath: 'https://unpkg.com/tesseract.js@v2.1.0/dist/worker.min.js',
                          langPath: 'https://tessdata.projectnaptha.com/4.0.0',
                          corePath: 'https://unpkg.com/tesseract.js-core@2.1.0/tesseract-core.wasm.js'
                      });
                      await worker.load();
                      await worker.loadLanguage('jpn+eng');
                      await worker.initialize('jpn+eng');
                      await worker.setParameters({
                          preserve_interword_spaces: '1',
                          tessedit_pageseg_mode: Tesseract.PSM.SINGLE_BLOCK, // ★追加: 処理モードをブロック認識に固定して若干の高速化を狙う
                      });
                      scheduler.addWorker(worker);
                  })());
              }
              
              await Promise.all(workerPromises);
              currentScheduler = scheduler; if (window.NLM) { window.NLM.__ocrScheduler = scheduler; } /* [majin-patch] 単一プールを保存 */
              
              // 通知が出ていた場合は消す
              if (ocrOriginalImage) {
                  hideNotification();
              }
              
              console.log("Tesseract Scheduler initialized.");
              return currentScheduler;
          })();
      
          return schedulerInitPromise;
      }
      
      // ==========================================
      // OCR一括処理マネージャー (修正版: "|11"などのパターン除外追加)
      // ==========================================
      async function startBatchOCR(objects) { console.log("[DEBUG] startBatchOCR CALLED. Objects: ", objects ? objects.length : 0, "Candidates:", objects.filter(o => o._isOcrCandidate).length);
          const targets = objects.filter(o => o._isOcrCandidate);
          
          if (targets.length === 0) { showNotification('解析完了', 'OCR対象のテキストがありませんでした', 'success'); return; }
      
          // 通知表示
          showNotification("OCR準備中", `${targets.length}箇所のテキストを解析します`, "processing");
      
          try {
              // スケジューラー初期化 (既存があれば再利用)
              await initTesseractScheduler();
      
              // 進捗管理用変数
              let completedCount = 0;
              const totalCount = targets.length;
      
// ★修正: 詳細なエラー理由を表示し、座標取得を強化したOCR処理ロジック
            const processObjectOCR = async (obj) => {
                obj.isOcrProcessing = true;
                
                let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                let isTargetInverted = false;

                // --- ★修正ポイント: 座標取得ロジックの優先順位変更 ---
                
                // パターンA: オブジェクト自体が正しい座標情報を持っている場合（結合グループはこれ）
                // これを最優先にすることで、グループの中身を探索して失敗するのを防ぐ
                if (obj.originalRect) {
                    minX = obj.originalRect.x;
                    minY = obj.originalRect.y;
                    maxX = obj.originalRect.x + obj.originalRect.w;
                    maxY = obj.originalRect.y + obj.originalRect.h;
                    isTargetInverted = obj.isInverted;
                }
                // パターンB: グループだが自身の座標がない場合（中身から計算）
                else if (obj.type === 'group') {
                    const subObjects = obj.getObjects().filter(o => !o.isVisualFrame);
                    subObjects.forEach(o => {
                        if (o.originalRect) {
                            minX = Math.min(minX, o.originalRect.x);
                            minY = Math.min(minY, o.originalRect.y);
                            maxX = Math.max(maxX, o.originalRect.x + o.originalRect.w);
                            maxY = Math.max(maxY, o.originalRect.y + o.originalRect.h);
                        }
                    });
                    isTargetInverted = subObjects.some(o => o.isInverted);
                }

                // ★追加: 座標詳細チェック & エラー表示機能
                let errorReasons = [];
                const imgW = ocrOriginalImage.width;
                const imgH = ocrOriginalImage.height;

                // 1. 無効値チェック
                if (minX === Infinity || maxX === -Infinity) errorReasons.push("座標取得失敗(Infinity)");
                
                // 2. マージン適用前のチェック
                if (minX < 0) errorReasons.push(`X負値(${minX})`);
                if (minY < 0) errorReasons.push(`Y負値(${minY})`);
                
                // マージン適用
                const margin = 2; 
                const cropX = Math.max(0, minX - margin);
                const cropY = Math.max(0, minY - margin);
                const cropW = Math.min(imgW, maxX + margin) - cropX;
                const cropH = Math.min(imgH, maxY + margin) - cropY;

                // 3. マージン適用後の整合性チェック
                if (cropW <= 0) errorReasons.push(`幅ゼロ以下(${cropW})`);
                if (cropH <= 0) errorReasons.push(`高ゼロ以下(${cropH})`);
                if (cropX + cropW > imgW) errorReasons.push(`右端超過(${cropX + cropW} > ${imgW})`);
                if (cropY + cropH > imgH) errorReasons.push(`下端超過(${cropY + cropH} > ${imgH})`);

                // エラーがある場合はここで中断して理由を表示
                if (errorReasons.length > 0) {
                    const reasonStr = errorReasons.join(", ");
                    console.error(`OCR座標エラー: ${reasonStr}`, obj);
                    obj.detectedText = `(座標エラー: ${reasonStr})`;
                    obj.isOcrProcessing = false;
                    completedCount++;
if (typeof onProgress === 'function') onProgress((completedCount / totalCount) * 100, "OCR解析中", `完了: ${completedCount} / ${totalCount}`);
                    canvas.requestRenderAll();
                    return;
                }

                const cropCanvas = document.createElement('canvas');
                cropCanvas.width = cropW;
                cropCanvas.height = cropH;
                const cropCtx = cropCanvas.getContext('2d');
                
                try {
                    if (CURRENT_PARAMS.useOriginalForOcr) {
                        cropCtx.drawImage(ocrOriginalImage, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
                    } else {
                        cropCtx.drawImage(ocrProcessedCanvasElement, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
                    }
                } catch (drawErr) {
                    console.error("Canvas描画エラー", drawErr);
                    obj.detectedText = `(描画エラー: ${drawErr.message})`;
                    obj.isOcrProcessing = false;
                    completedCount++;
if (typeof onProgress === 'function') onProgress((completedCount / totalCount) * 100, "OCR解析中", `完了: ${completedCount} / ${totalCount}`);
                    return;
                }

                // グラデーション判定
                // ★修正: 暗色救済・カラー文字・白抜き文字の場合はグラデーション判定をスキップする (誤判定防止)
                const isRescueOrColor = obj.isRescuedDarkGroup || obj.isRescuedDark || obj.isColorChar || isTargetInverted;
                let gradientCheck = { hasGradient: false, reason: "スキップ (暗色救済/白抜き)" };
                
                if (!isRescueOrColor) {
                    gradientCheck = detectThreePointGradient(cropCtx, cropCanvas.width, cropCanvas.height);
                }
                
                if (gradientCheck.hasGradient) {
                    obj.detectedText = "";
                    obj.isOcrProcessing = false;
                    stripVisualAids(obj, `グラデーション背景検出 - OCR除外<br><span class="text-[9px] text-slate-400">${gradientCheck.reason}</span>`);
                    canvas.requestRenderAll();
                    completedCount++;
if (typeof onProgress === 'function') onProgress((completedCount / totalCount) * 100, "OCR解析中", `完了: ${completedCount} / ${totalCount}`);
                    return;
                } else {
                    const checkInfo = `<br><span class="text-[9px] text-slate-400">背景色判定: OK (${gradientCheck.reason})</span>`;
                    if (obj.inclusionReason && !obj.inclusionReason.includes("背景色判定")) {
                        obj.inclusionReason += checkInfo;
                    }
                }

                // ★修正: useOriginalForOcr が true であっても、isTargetInverted (白抜き文字) であれば必ず階調反転処理を実行する
                // これにより、青背景などの上の白文字が Tesseract に黒文字として渡るため認識率が劇的に向上する
                const shouldUseFilter = !CURRENT_PARAMS.useOriginalForOcr || isTargetInverted;
                if (shouldUseFilter && isTargetInverted) {
                    const imgData = cropCtx.getImageData(0, 0, cropCanvas.width, cropCanvas.height);
                    const data = imgData.data;
                    for (let k = 0; k < data.length; k += 4) {
                        data[k] = 255 - data[k];         
                        data[k+1] = 255 - data[k+1]; 
                        data[k+2] = 255 - data[k+2]; 
                    }
                    cropCtx.putImageData(imgData, 0, 0);
                }
                
                const contentBounds = getContentBounds(obj);
                const currentH = contentBounds.height;
                const currentW = contentBounds.width;

                // 高さチェック
                if (currentH < 5) {
                    obj.detectedText = "";
                    obj.isOcrProcessing = false;
                    stripVisualAids(obj, `高さ過小 (${currentH.toFixed(1)}px < 5px) - ノイズ判定`);
                    canvas.requestRenderAll();
                    completedCount++;
if (typeof onProgress === 'function') onProgress((completedCount / totalCount) * 100, "OCR解析中", `完了: ${completedCount} / ${totalCount}`);
                    return;
                }

                // OCR実行用の画像作成
                const segments = [{x:0, y:0, w:cropCanvas.width, h:cropCanvas.height}];
                let rawSegments = [];
                let cleanSegments = [];
                let lastDebugImage = null;

                // セグメント処理ループ
                //   ※ OCR画像前処理 (ABC padding) + Tesseract + マーカー除去 + リトライ判定は
                //     PureTextProcessingEngine.runOcrPipeline() に集約。
                //     エディタの単独OCRからも同じメソッドが呼ばれるため、ロジックは完全に共通。
                for (let k = 0; k < segments.length; k++) {
                    const seg = segments[k];
                    const segCanvas = document.createElement('canvas');
                    segCanvas.width = seg.w;
                    segCanvas.height = seg.h;
                    const segCtx = segCanvas.getContext('2d');
                    segCtx.fillStyle = '#ffffff';
                    segCtx.fillRect(0, 0, seg.w, seg.h);
                    segCtx.drawImage(cropCanvas, seg.x, seg.y, seg.w, seg.h, 0, 0, seg.w, seg.h);

                    // 共通OCRパイプライン
                    const result = await PureTextProcessingEngine.runOcrPipeline(segCanvas, currentScheduler);

                    if (result.cleanText) {
                        rawSegments.push(result.rawText);
                        cleanSegments.push(result.cleanText);
                    }
                    lastDebugImage = result.paddedImage;
                }

                obj.rawOcrText = rawSegments.join(" ");
                let text = cleanSegments.join(" ");

                // --- テキスト整形・補正処理（共通ロジックを静的メソッドに集約） ---
                //   ※ ここを変更したい場合は PureTextProcessingEngine.postProcessOcrText() を編集してください。
                //     単独OCR (processRegionToJSON, エディタのTesseract直接OCR) と完全に同じロジックが適用されます。
                text = PureTextProcessingEngine.postProcessOcrText(text);

                // 特定条件の「0」ノイズ除去（画像コンテキスト依存のためエンジン内部に残置）
                if (ocrOriginalImage) {
                    const wRatio = currentW / ocrOriginalImage.width;
                    const hRatio = currentH / ocrOriginalImage.height;

                    if (wRatio < 0.02 && hRatio < 0.02 &&
                        gradientCheck.maxDiff >= 7.0 &&
                        text === '0') {

                        text = "";
                        stripVisualAids(obj, `極小ノイズ ("0" & W,H<2% & 色差${gradientCheck.maxDiff.toFixed(1)}>=7) - 除外`);
                        canvas.requestRenderAll();
                    }
                }

                obj.detectedText = text || "（文字を検出できませんでした）";

                // 各種ノイズ判定
                const isTinyHeight = currentH <= (ocrOriginalImage.height * 0.005);
                const isSingleCharNotZero = (Array.from(text).length === 1 && text !== '0' && text !== ':');
                const isMultiMemberZero = (text === '0' && obj.type === 'group' && obj.getObjects().filter(o => !o.isVisualFrame).length >= 2);
                const isOnlySymbols = /^[\.\,\-\_;!?'"()\[\]{}<>|/@#$%^&*~`+\\=‘’“”・…]+$/.test(text);
                const hasAtOrReg = /[@®]/.test(text);
                const meaningfulChars = text.match(/[a-zA-Z0-9一-龠ぁ-んァ-ヶ]/g) || [];
                const isInvalidSpecialUsage = hasAtOrReg && meaningfulChars.length < 2;
                const hasRepeatedNoise = /([a-zA-Z\[\]])\1{4,}/.test(text);
                
                if (!text || isOnlySymbols || isInvalidSpecialUsage || isSingleCharNotZero || isMultiMemberZero || (isTinyHeight && isSingleCharNotZero) || hasRepeatedNoise) {
                    let reason = "OCR結果がノイズ判定";
                    if (isOnlySymbols) reason += "（記号のみ）";
                    else if (isInvalidSpecialUsage) reason += "（@/®有効文字不足）";
                    else if (hasRepeatedNoise) reason += "（連続文字ノイズ）";
                    else if (isMultiMemberZero) reason += "（複数構成かつ0単体）";
                    else if (isSingleCharNotZero) reason += "（単一文字除外: 0以外）";
                    else if (isTinyHeight && isSingleCharNotZero) reason += `（極小高さ:${(currentH/ocrOriginalImage.height*100).toFixed(2)}% & 単一文字）`;

                    stripVisualAids(obj, reason);
                    canvas.requestRenderAll();
                }

                // 内部空隙警告（星マーク）のフィルタリング & 再OCRへのフラグ立て
                if (obj.type === 'group' && obj._isOcrCandidate) {
                    const gapStars = obj.getObjects().filter(o => o.isStar && o.starReason && o.starReason.startsWith("内部空隙警告"));
                    
                    if (gapStars.length > 0) {
                        let shouldShowStar = false;

                        if (text) {
                            const chars = "a-zA-Z0-9_\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FAF";
                            const hasSuspiciousSpace = new RegExp(`[${chars}]\\s[${chars}]`).test(text);
                            const hasDotSpace = /\.\s|\s\./.test(text);
                            
                            if (hasSuspiciousSpace && !hasDotSpace) {
                                shouldShowStar = true;
                            }
                        }

                        if (!shouldShowStar) {
                            gapStars.forEach(star => {
                                star.visible = false;
                                star.opacity = 0; 
                            });
                            obj.dirty = true;
                        } else {
                            gapStars.forEach(star => {
                                star.visible = true;
                                star.opacity = 1;
                            });
                            obj.dirty = true;
                        }
                        canvas.requestRenderAll();
                    }
                }

                if (obj._isOcrCandidate && text) {
                    const textNoSpace = text.replace(/\s/g, '');
                    if (textNoSpace.length === 2 && /^[0oO]+$/.test(textNoSpace)) {
                        stripVisualAids(obj, `丸型図形誤認識 (文字列 "${textNoSpace}") - OCR除外`);
                        canvas.requestRenderAll();
                    }
                    
                    if (textNoSpace.length === 2 && /^[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ]+$/.test(textNoSpace)) {
                        stripVisualAids(obj, `小文字日本語誤認識 (文字列 "${textNoSpace}") - OCR除外`);
                        canvas.requestRenderAll();
                    }
                }

                if (obj._isOcrCandidate && obj.calculatedFillRate !== undefined && obj.calculatedFillRate > 99.0) {
                    const solidShapeTextRegex = /^[\.\,\-\_:;!?'"()\[\]{}<>|/@#$%^&*~`+\\=‘’“”・…—─一ー]+$/;
                    if (solidShapeTextRegex.test(text)) {
                         stripVisualAids(obj, `ベタ塗り図形判定 (充填率:${obj.calculatedFillRate.toFixed(1)}% & 記号/線のみ) - OCR除外`);
                         canvas.requestRenderAll();
                    }
                }

                if (obj._isOcrCandidate && text && ocrOriginalImage) {
                    const wRatio = currentW / ocrOriginalImage.width;
                    const hRatio = currentH / ocrOriginalImage.height;
                    
                    // 極小ノイズ判定
                    if (wRatio < 0.02 && hRatio < 0.02) {
                        const textNoSpace = text.replace(/\s/g, '');
                        const totalLen = Array.from(textNoSpace).length;
                        
                        if (totalLen > 0) {
                            const nonSymbolRegex = /[a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
                            let symbolCount = 0;
                            for (const char of Array.from(textNoSpace)) {
                                if (!nonSymbolRegex.test(char)) symbolCount++;
                            }
                            const symbolRatio = symbolCount / totalLen;
                            if (symbolRatio >= 0.5) {
                                const isPercentage = /\d+%$/.test(textNoSpace);
                                if (!isPercentage) {
                                    stripVisualAids(obj, `極小ノイズ (2%未満 & 記号率 ${(symbolRatio*100).toFixed(0)}% >= 50%) - 誤認識除外`);
                                    canvas.requestRenderAll();
                                }
                            }
                        }

                        if (obj._isOcrCandidate) { 
                            const isAlnumSymbolSpaceOnly = /^[a-zA-Z0-9\p{P}\p{S}\s]+$/u.test(text);
                            const hasSpace = text.includes(' ');
                            if (isAlnumSymbolSpaceOnly && hasSpace) {
                                stripVisualAids(obj, `極小ノイズ (2%未満 & スペース区切り英数記号) - 誤認識除外`);
                                canvas.requestRenderAll();
                            }
                        }
                    }

                    // "|"系ノイズの判定
                    const isTinyPipe = (wRatio <= 0.01 && hRatio <= 0.01 && text.startsWith('|'));
                    const isPipeNumberPattern = /^\|\s*\d/.test(text);

                    if (isTinyPipe || isPipeNumberPattern) {
                        stripVisualAids(obj, `ノイズ判定 ("|"開始図形誤認識) - 除外`);
                        canvas.requestRenderAll();
                    }
                    
                    // ★追加: 高度な幻覚・ノイズ文字列の汎用検知メカニズム (ユーザー要望による仕組み化)
                    // 小さい要素（高さ2.5%未満 または 実寸35px以下）から抽出された不自然な文字列を検出
                    const isSmallElement = hRatio < 0.025 || currentH <= 35;
                    const noSpaceClean = text.replace(/\s/g, '');
                    
                    if (isSmallElement && noSpaceClean.length > 0 && noSpaceClean.length <= 6) {
                        // 1. 小文字のひらがな/カタカナ + 英小文字 の不自然な混在 (例: ゅfe)
                        const hasSmallKanaAndEng = /[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ]/.test(noSpaceClean) && /[a-z]/.test(noSpaceClean);
                        
                        // 2. 漢字 + 英小文字 の不自然な混在 (例: 哉ms)
                        const hasKanjiAndEngLower = /[一-龠]/.test(noSpaceClean) && /[a-z]/.test(noSpaceClean);
                        
                        // 3. 数字・記号・アルファベットのランダムな混在 (例: 0I0】m)
                        const hasSymbolMatch = /[^\w\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/.test(noSpaceClean);
                        const hasNumMatch = /[0-9]/.test(noSpaceClean);
                        const hasEngMatch = /[a-zA-Z]/.test(noSpaceClean);
                        const isRandomMix = hasSymbolMatch && hasNumMatch && hasEngMatch;
                        
                        // 4. "0"の単独誤認識 (極小サイズの孤立した0)
                        const isIsolatedZero = noSpaceClean === '0';

                        if (hasSmallKanaAndEng || hasKanjiAndEngLower || isRandomMix || isIsolatedZero) {
                            let reasonDetail = "";
                            if (hasSmallKanaAndEng) reasonDetail = "小書き仮名+英小文字";
                            else if (hasKanjiAndEngLower) reasonDetail = "漢字+英小文字";
                            else if (isRandomMix) reasonDetail = "英数字記号の不自然な混在";
                            else if (isIsolatedZero) reasonDetail = "極小孤立ゼロ";
                            
                            stripVisualAids(obj, `高度なノイズ検知 (${reasonDetail}: "${noSpaceClean}") - イラスト誤認識判定`);
                            canvas.requestRenderAll();
                        }
                    }
                }

                if ((obj.isRescuedDarkGroup || obj.isRescuedDark || obj.isColorChar || obj.isBlackish) && obj._isOcrCandidate && text) {
                    const textNoSpace = text.replace(/\s/g, '');
                    const totalLen = Array.from(textNoSpace).length;
                    
                    if (totalLen > 0) {
                        const nonSymbolRegex = /[a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
                        let symbolCount = 0;
                        for (const char of Array.from(textNoSpace)) {
                            if (!nonSymbolRegex.test(char)) symbolCount++;
                        }
                        
                        const symbolRatio = symbolCount / totalLen;
                        let threshold = 1.0;
                        let typeLabel = "";

                        if (obj.isRescuedDarkGroup || obj.isRescuedDark) {
                            threshold = 0.40; typeLabel = "暗色救済";
                        } else if (obj.isColorChar) {
                            threshold = 0.50; typeLabel = "カラー文字";
                        } else if (obj.isBlackish) {
                            threshold = 0.50; typeLabel = "黒文字";
                        }

                        if (threshold < 1.0 && symbolRatio >= threshold) {
                            const isPercentage = /\d+%$/.test(textNoSpace);
                            if (!isPercentage) {
                                stripVisualAids(obj, `記号過多 (${typeLabel}除外: 記号率 ${(symbolRatio*100).toFixed(0)}% >= ${(threshold*100).toFixed(0)}%) - イラスト誤認識判定`);
                                canvas.requestRenderAll();
                            }
                        }
                    }
                }

                if ((obj.isColorChar || obj.isRescuedDarkGroup || obj.isRescuedDark) && obj._isOcrCandidate && text) {
                const meaningfulText = text.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, '');
                
                if (meaningfulText.length === 2 && meaningfulText !== 'AI') {
                    const typeLabel = (obj.isRescuedDarkGroup || obj.isRescuedDark) ? "暗色救済" : "カラー文字";
                    stripVisualAids(obj, `${typeLabel}除外 (有効文字数2文字: "${meaningfulText}") - イラスト誤認識判定`);
                    canvas.requestRenderAll();
                }
            }

                if (lastDebugImage) {
                    obj.ocrDebugImageSrc = lastDebugImage.toDataURL();
                } else {
                    const debugWithAbc = prepareImageForOCR(cropCanvas);
                    obj.ocrDebugImageSrc = debugWithAbc.toDataURL();
                }
                
                obj.isOcrProcessing = false;

                if (canvas.getActiveObject() === obj) {
                    ocrResultText.value = obj.detectedText;
                    ocrRawText.value = obj.rawOcrText;
                    ocrDebugImg.src = obj.ocrDebugImageSrc;
                    handleSelection({ selected: [obj] });
                }
                
                const notebookLmRegex = /Notebook\s*LM[\s\.\-_]*$/i;
                if (ocrOriginalImage && text && notebookLmRegex.test(text.trim())) {
                    const centerX = obj.left;
                    const centerY = obj.top;
                    const currentCanvasW = canvas.width;
                    const currentCanvasH = canvas.height;
                    
                    if (centerX > currentCanvasW * 0.5 && centerY > currentCanvasH * 0.5) {
                        obj.detectedText = "";
                        stripVisualAids(obj, "NotebookLMロゴ (自動除外)");
                        canvas.requestRenderAll();
                    }
                }

                completedCount++;
if (typeof onProgress === 'function') onProgress((completedCount / totalCount) * 100, "OCR解析中", `完了: ${completedCount} / ${totalCount}`);
                showNotification("OCR解析中", `完了: ${completedCount} / ${totalCount}`, "processing");
            };
            
            
            
              // ジョブ実行
              await Promise.all(targets.map(obj => processObjectOCR(obj)));
              
              // =========================================================
              // ★新規追加: 星マークが残っているグループの分割・再OCR処理
              // =========================================================
              
              // 1. 分割対象の検出: OCR後も「内部空隙警告」の星が表示(visible)されているグループ
              const groupsToSplit = targets.filter(obj => {
                  if (obj.type !== 'group' || !obj.visible) return false;
                  if (!obj._isOcrCandidate) return false; // 除外済みのものは対象外
                  
                  // visibleな「内部空隙警告」星を持っているか
                  const activeGapStars = obj.getObjects().filter(o => 
                      o.isStar && o.visible && o.starReason && o.starReason.startsWith("内部空隙警告")
                  );
                  return activeGapStars.length > 0;
              });
      
              if (groupsToSplit.length > 0) {
                  console.log(`分割再解析: ${groupsToSplit.length}個のグループを分割します`);
                  showNotification("詳細解析", "空隙によりグループを分割して再解析中...", "info");
                  
                  // 2. グループ分割処理を実行 (Canvas上のオブジェクトも更新されます)
                  // ※後述の splitGroupsAtStarsAndRefactor 関数を使用
                  const newTargets = await splitGroupsAtStarsAndRefactor(groupsToSplit);
                  
                  if (newTargets.length > 0) {
                      // 3. 新しいグループに対して再帰的にOCRを実行
                      await startBatchOCR(newTargets);
                      return; // 再帰先で完了通知が出るのでここで終了
                  }
              }
              // =========================================================
              
              
              const overlappedGroups = await checkAndMergeOverlaps(all);
              
              if (overlappedGroups.length > 0) {
                  console.log(`重なり検出: ${overlappedGroups.length}個の結合グループを再解析します`);
                  showNotification("仕上げ処理", "重なった領域を結合して再解析中...", "info");
                  
                  // 結合された新しいグループだけでOCRを実行（再帰）
                  await startBatchOCR(overlappedGroups);
                  return; // 再帰先で完了通知が出るのでここで終了
              }
      
              showNotification("解析完了", "すべてのテキスト検出が終了しました", "success");
      
          } catch (e) {
              console.error(e);
              showNotification("解析エラー", "OCR処理中にエラーが発生しました", "error");
          }
      }
      
      
      
      
      
      
      
      // ==========================================
      // ヘルパー関数群 (後半)
      // ==========================================
      
      function applySharpenFilter(ctx, w, h) {
          const imgData = ctx.getImageData(0, 0, w, h);
          const src = imgData.data;
          const output = ctx.createImageData(w, h);
          const dst = output.data;
          const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0];
          for (let y = 1; y < h - 1; y++) {
              for (let x = 1; x < w - 1; x++) {
                  let r = 0, g = 0, b = 0;
                  for (let ky = -1; ky <= 1; ky++) {
                      for (let kx = -1; kx <= 1; kx++) {
                          const pixelIdx = ((y + ky) * w + (x + kx)) * 4;
                          const weight = kernel[(ky + 1) * 3 + (kx + 1)];
                          r += src[pixelIdx] * weight;
                          g += src[pixelIdx + 1] * weight;
                          b += src[pixelIdx + 2] * weight;
                      }
                  }
                  const idx = (y * w + x) * 4;
                  dst[idx] = Math.min(255, Math.max(0, r));
                  dst[idx + 1] = Math.min(255, Math.max(0, g));
                  dst[idx + 2] = Math.min(255, Math.max(0, b));
                  dst[idx + 3] = src[idx + 3];
              }
          }
          ctx.putImageData(output, 0, 0);
      }
      
      function findConnectedComponents(width, height, data) {
          const visited = new Uint8Array(width * height);
          const blobs = [];
          const minBlobSize = 50;
      
          for (let y = 0; y < height; y++) {
              for (let x = 0; x < width; x++) {
                  const idx = (y * width + x);
                  const pixelIdx = idx * 4;
                  if (data[pixelIdx + 3] > 0 && visited[idx] === 0) {
                      const currentBlob = { minX: x, maxX: x, minY: y, maxY: y, pixels: [] };
                      const stack = [idx];
                      visited[idx] = 1;
                      while(stack.length > 0) {
                          const currIdx = stack.pop();
                          const cx = currIdx % width;
                          const cy = Math.floor(currIdx / width);
                          const cPixelIdx = currIdx * 4;
                          if (cx < currentBlob.minX) currentBlob.minX = cx;
                          if (cx > currentBlob.maxX) currentBlob.maxX = cx;
                          if (cy < currentBlob.minY) currentBlob.minY = cy;
                          if (cy > currentBlob.maxY) currentBlob.maxY = cy;
                          currentBlob.pixels.push({ x: cx, y: cy, r: data[cPixelIdx], g: data[cPixelIdx+1], b: data[cPixelIdx+2], a: data[cPixelIdx+3] });
                          
                          const neighbors = [currIdx + 1, currIdx - 1, currIdx + width, currIdx - width];
                          const checks = [cx + 1 < width, cx - 1 >= 0, cy + 1 < height, cy - 1 >= 0];
                          for(let i=0; i<4; i++) {
                              if(checks[i]) {
                                  const nIdx = neighbors[i];
                                  if(visited[nIdx] === 0 && data[nIdx * 4 + 3] > 0) {
                                      visited[nIdx] = 1;
                                      stack.push(nIdx);
                                  }
                              }
                          }
                      }
                      if (currentBlob.pixels.length > minBlobSize) blobs.push(currentBlob);
                  }
              }
          }
          console.log("[DEBUG] findConnectedComponents finished! Blobs length: ", blobs.length); return blobs;
      }
      
      // ★修正: 色抽出ロジックを「濃い部分の平均」に変更したバージョン
      function createFabricObjectFromBlob(blob, origW, origH, totalImagePixels) {
          return new Promise((resolve) => {
              const width = blob.maxX - blob.minX + 1;
              const height = blob.maxY - blob.minY + 1;
              const blobCanvas = document.createElement('canvas');
              blobCanvas.width = width;
              blobCanvas.height = height;
              const blobCtx = blobCanvas.getContext('2d');
              
              let totalR = 0, totalG = 0, totalB = 0;
              let totalSqR = 0, totalSqG = 0, totalSqB = 0;
              let nonBlackPixelCount = 0;
              let highSaturationPixelCount = 0;
              let hueSumCos = 0, hueSumSin = 0, validHueCount = 0;
              let pixelCount = 0;
      
              // ★追加: 正確な色計算のために全ピクセルを一時保存する配列
              const pixelCollection = [];
      
              if (blob.isFullImage) {
                  const tempC = document.createElement('canvas');
                  tempC.width = origW; tempC.height = origH;
                  tempC.getContext('2d').putImageData(blob.imageData, 0, 0);
                  blobCtx.drawImage(tempC, blob.minX, blob.minY, width, height, 0, 0, width, height);
                  const d = blobCtx.getImageData(0,0,width,height).data;
                  for(let i=0; i<d.length; i+=4) {
                      if(d[i+3]>0) { processPixel(d[i],d[i+1],d[i+2]); pixelCount++; }
                  }
              } else {
                  const imgData = blobCtx.createImageData(width, height);
                  const d = imgData.data;
                  blob.pixels.forEach(p => {
                      const idx = ((p.y - blob.minY) * width + (p.x - blob.minX)) * 4;
                      d[idx]=p.r; d[idx+1]=p.g; d[idx+2]=p.b; d[idx+3]=p.a;
                      processPixel(p.r, p.g, p.b);
                      pixelCount++;
                  });
                  blobCtx.putImageData(imgData, 0, 0);
              }
      
              function processPixel(r, g, b) {
                  totalR+=r; totalG+=g; totalB+=b;
                  totalSqR+=r*r; totalSqG+=g*g; totalSqB+=b*b;
                  
                  // ★追加: 色計算用にピクセルを保存 (明るさも計算しておく)
                  pixelCollection.push({ r: r, g: g, b: b, brightness: r + g + b });
      
                  const avg = (r+g+b)/3;
                  const max = Math.max(r,g,b), min = Math.min(r,g,b);
                  const sat = max-min;
                  
                  if(avg > 180 || (avg > 70 && sat > 40)) nonBlackPixelCount++;
                  if(sat > 50 && avg > 70) highSaturationPixelCount++;
                  
                  if(sat>20 && max>30) {
                      let h = 0; const d = max-min;
                      if(d!==0) {
                          if(max===r) h=(g-b)/d+(g<b?6:0);
                          else if(max===g) h=(b-r)/d+2;
                          else h=(r-g)/d+4;
                          h/=6;
                      }
                      const angle = h * Math.PI * 2;
                      hueSumCos += Math.cos(angle);
                      hueSumSin += Math.sin(angle);
                      validHueCount++;
                  }
              }
      
              // ★復活: 黒文字とカラー文字を分離して判定
              let isBlackish = false;
              let isColorChar = false;
              let exclusionReason = null;
              let inclusionReason = null;
              let statsData = {};
              
              // ★変更: 最終的な色（JSON出力用）をここで計算
              let finalColor = { r: 0, g: 0, b: 0 };
      
              if (pixelCount > 0) {
                  // 1. 判定ロジック用の統計データ計算 (ここは元のまま)
                  const nonBlackRatio = nonBlackPixelCount / pixelCount;
                  const highSaturationRatio = highSaturationPixelCount / pixelCount;
                  const meanR = totalR/pixelCount, meanG = totalG/pixelCount, meanB = totalB/pixelCount;
                  const mean = (meanR+meanG+meanB)/3;
                  
                  const varR = (totalSqR/pixelCount)-(meanR*meanR);
                  const varG = (totalSqG/pixelCount)-(meanG*meanG);
                  const varB = (totalSqB/pixelCount)-(meanB*meanB);
                  const maxStdDev = Math.sqrt(Math.max(0, Math.max(varR, varG, varB)));
      
                  let hueVariance = 1.0;
                  if (validHueCount > 0) {
                      const r = Math.sqrt((hueSumCos/validHueCount)**2 + (hueSumSin/validHueCount)**2);
                      hueVariance = 1 - r;
                  } else { hueVariance = 0; }
      
                  statsData = { nonBlackRatio, highSaturationRatio, meanBrightness: mean, maxStdDev, hueVariance };
      
                  // 2. ★追加: JSON出力用の「濃い色」の抽出ロジック
                  // ピクセルを明るさ(brightness)の昇順（暗い順）に並べ替える
                  pixelCollection.sort((a, b) => a.comparison - b.comparison); // 修正: 単純ソートより高速化のため明るさ比較
                  pixelCollection.sort((a, b) => a.brightness - b.brightness);
      
                  // 最も暗い上位20%のピクセルだけを取り出す（最低5ピクセルは確保）
                  const coreSampleCount = Math.max(5, Math.floor(pixelCollection.length * 0.20));
                  const corePixels = pixelCollection.slice(0, coreSampleCount);
                  
                  // その20%の平均をとる -> これが「文字の芯」の色になる
                  let sumCoreR = 0, sumCoreG = 0, sumCoreB = 0;
                  corePixels.forEach(p => {
                      sumCoreR += p.r;
                      sumCoreG += p.g;
                      sumCoreB += p.b;
                  });
                  
                  finalColor = {
                      r: Math.floor(sumCoreR / corePixels.length),
                      g: Math.floor(sumCoreG / corePixels.length),
                      b: Math.floor(sumCoreB / corePixels.length)
                  };
      
                  // --- 以下、元の判定ロジック ---
                  
                  // 0. 暗色救済判定 (白背景上のカード状要素に描かれた黒文字)
                  const isCoreDark = finalColor.r < 60 && finalColor.g < 60 && finalColor.b < 60;
                  const isBgWhite = mean >= 200; // 平均輝度が白に近い
                  
                  if (isCoreDark && isBgWhite) {
                      isBlackish = true;
                      inclusionReason = `白背景上の暗色文字として救済されました (芯の色: rgb(${finalColor.r},${finalColor.g},${finalColor.b}), 背景輝度: ${mean.toFixed(0)})`;
                  }
                  // 1. 黒文字判定 (アンチエイリアスの影響や極小文字を考慮して閾値を安全に緩和)
                  else if (nonBlackRatio < 0.45 && highSaturationRatio < 0.06) {
                      isBlackish = true;
                      inclusionReason = `黒に近い単色です (黒ピクセル率: ${Math.round((1-nonBlackRatio)*100)}%)`;
                  } else {
                      if (nonBlackRatio >= 0.45) exclusionReason = `黒以外の色が多すぎます (黒率: ${Math.round((1-nonBlackRatio)*100)}%)`;
                      else exclusionReason = `鮮やかな色が混入しています`;
                  }
      
                  // 2. カラー文字判定（黒文字でなければ）
                  if (!isBlackish) {
                      const aspect = width / height;
                      const isSmallObject = (width < 10 || height < 10 || pixelCount < 200);
                      const isMonochromatic = (maxStdDev < 75 && hueVariance < 0.25) || isSmallObject;
                      const isDarkEnough = mean < 250;
                      const isNotExtreme = aspect > 0.08 && aspect < 16.0;
      
                      if (isMonochromatic && isDarkEnough && isNotExtreme) {
                          isColorChar = true;
                          inclusionReason = `同系色のカラー文字です (色相分散: ${hueVariance.toFixed(2)})`;
                          exclusionReason = null;
                      } else {
                          if (!isNotExtreme) exclusionReason = `形状が極端です (縦横比: ${aspect.toFixed(2)})`;
                          else if (!isDarkEnough) exclusionReason = `色が薄すぎます (輝度: ${mean.toFixed(0)} >= 基準: 250)`;
                          else exclusionReason = `色のばらつき過多 (偏差: ${maxStdDev.toFixed(1)} >= 75 / 色相分散: ${hueVariance.toFixed(2)} >= 0.25)`;
                      }
                  }
              } else { exclusionReason = "ピクセル情報なし"; }
      
              if (pixelCount < 50) { 
                  isBlackish = false; 
                  isColorChar = false; 
                  exclusionReason = `サイズ過小 (面積: ${pixelCount}px² < 基準: 50px²)`; 
              }
              
              if (blob.minX > 2100 && blob.minX < 2700 && blob.minY > 1250 && blob.minY < 1350) {
                  console.log("[DEBUG-AI-GOV]", {
                      minX: blob.minX,
                      minY: blob.minY,
                      width: width,
                      height: height,
                      pixelCount: pixelCount,
                      isBlackish: isBlackish,
                      isColorChar: isColorChar,
                      statsData: statsData,
                      exclusionReason: exclusionReason
                  });
              }
              
              fabric.Image.fromURL(blobCanvas.toDataURL(), function(oImg) {
                  const currentCanvasW = canvas.width;
                  const scale = currentCanvasW / origW;
      
                  oImg.set({
                      scaleX: scale, scaleY: scale,
                      left: (blob.minX + width/2) * scale,
                      top: (blob.minY + height/2) * scale,
                      originX: 'center', originY: 'center',
                      hasControls: false,
                      hasBorders: false,
                      lockMovementX: true,
                      lockMovementY: true,
                      hoverCursor: 'pointer'
                  });
      
                  oImg.isBlackish = isBlackish;
                  oImg.isColorChar = isColorChar;
                  oImg.exclusionReason = exclusionReason;
                  oImg.inclusionReason = inclusionReason;
                  oImg.pixelCount = pixelCount; console.log("[DEBUG] createFabricObjectFromBlob pixelCount: ", pixelCount);
                  oImg.stats = statsData;
                  // ★変更: ここで単純平均ではなく、計算した「濃い平均色」を使用
                  oImg.avgColor = finalColor;
                  oImg.originalRect = { x: blob.minX, y: blob.minY, w: width, h: height };
                  oImg.originalImageHeight = origH;
      
                  resolve(oImg);
              });
          });
      }      
      class UnionFind {
          constructor(elements) { this.parent = new Map(); elements.forEach(e => this.parent.set(e, e)); }
          find(x) { if (this.parent.get(x) !== x) this.parent.set(x, this.find(this.parent.get(x))); return this.parent.get(x); }
          union(x, y) { let rX = this.find(x), rY = this.find(y); if (rX !== rY) this.parent.set(rX, rY); }
      }
      
      // グローバル変数としてallを定義
      let all = [];
      
      // ★追加: 欠落していたgetContentBoundsとgetBounds関数を定義
      function getContentBounds(groupOrObj) {
          if (groupOrObj.type !== 'group') {
              return { width: groupOrObj.width * groupOrObj.scaleX, height: groupOrObj.height * groupOrObj.scaleY };
          }
          const objects = groupOrObj.getObjects();
          const gSX = groupOrObj.scaleX;
          const gSY = groupOrObj.scaleY;
          let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
          let hasContent = false;
          objects.forEach(o => {
              if (!o.isVisualFrame) {
                  hasContent = true;
                  const oW = o.width * o.scaleX;
                  const oH = o.height * o.scaleY;
                  const oL = o.left - oW / 2;
                  const oT = o.top - oH / 2;
                  if (oL < minX) minX = oL;
                  if (oT < minY) minY = oT;
                  if (oL + oW > maxX) maxX = oL + oW;
                  if (oT + oH > maxY) maxY = oT + oH;
              }
          });
          if (!hasContent) return { width: groupOrObj.width * gSX, height: groupOrObj.height * gSY };
          return { width: (maxX - minX) * gSX, height: (maxY - minY) * gSY };
      }
      
      function getBounds(obj) {
          const w = obj.width * obj.scaleX;
          const h = obj.height * obj.scaleY;
          return { left: obj.left - w/2, top: obj.top - h/2, right: obj.left + w/2, bottom: obj.top + h/2, width: w, height: h };
      }
      
      function groupAndAddObjects(objects) { console.log("[DEBUG] groupAndAddObjects CALLED! length: ", objects ? objects.length : 0);
          const SAFETY_THRESHOLD = 10.0;
          objects.forEach(obj => {
              if(obj.isBackground) return;
              const area = obj.originalRect.w * obj.originalRect.h;
              const density = area > 0 ? (obj.pixelCount / area) * 100 : 0;
              
              // 充填率 (0.0 ~ 1.0)
              const solidity = area > 0 ? (obj.pixelCount / area) : 0;
              // アスペクト比 (幅 / 高さ)
              const aspect = (obj.width * obj.scaleX) / (obj.height * obj.scaleY);
      
              if(density <= SAFETY_THRESHOLD) {
                  obj.isBlackish = false; obj.isColorChar = false;
                  // 密度判定の詳細化
                  obj.exclusionReason = `密度不足 (${density.toFixed(1)}% < 基準: ${SAFETY_THRESHOLD}%)`;
              }
      
              // アスペクト比が極端な場合 (1:10以上) は線とみなしてグループ化対象から除外
              if (aspect > 10.0 || aspect < 0.1) {
                  obj.isBlackish = false; 
                  obj.isColorChar = false;
                  const ratioStr = aspect > 1 ? aspect.toFixed(1) : (1/aspect).toFixed(1);
                  obj.exclusionReason = `形状除外 (アスペクト比 1:${ratioStr}) - 線/グラフ判定`;
              }
      
              // 暗色救済（グループ化前処理）
              if (obj.isColorChar && !obj.isBlackish) {
                  const c = obj.avgColor;
                  const brightness = (c.r + c.g + c.b) / 3;
                  
                  // ベタ塗り正方形（凡例パレット等）の除外判定
                  const isSolidSquare = (aspect > 0.8 && aspect < 1.2) && (solidity > 0.90);
      
                  if (brightness <= 120) {
                      if (!isSolidSquare) {
                          obj.isBlackish = true;
                          obj.isColorChar = false;
                          obj.isRescuedDark = true; // 救済フラグ
                          obj.inclusionReason += `<br><span class='text-[9px] text-emerald-600 font-bold'>※暗色のため黒文字へ統合 (輝度:${brightness.toFixed(0)} <= 120)</span>`;
                      } else {
                          obj.exclusionReason = `[救済スキップ] ベタ塗り図形判定 (充填率:${(solidity*100).toFixed(1)}% > 90% / 正方形)`;
                          obj.isColorChar = false;
                      }
                  }
              }
          });
      
          // 黒文字とカラー文字を別々にグループ化
          const blackish = objects.filter(o => o.isBlackish);
          const colorChar = objects.filter(o => o.isColorChar);
          const others = objects.filter(o => !o.isBlackish && !o.isColorChar);
      
          const ufBlack = new UnionFind(blackish);
          groupingProcess(blackish, ufBlack);
          
          const ufColor = new UnionFind(colorChar);
          groupingProcess(colorChar, ufColor);
      
          const blackGroups = generateGroups(blackish, ufBlack, 'red');
          const colorGroups = generateGroups(colorChar, ufColor, 'blue');
      
          const allCandidates = [...blackGroups, ...colorGroups, ...others]; console.log(`[DEBUG] blackGroups: ${blackGroups.length}, colorGroups: ${colorGroups.length}, others: ${others.length}`);
      
          const filterGroups = (groups) => {
              for (const item of groups) {
                  if (!item._isOcrCandidate) continue;
                  if (item._originalMemberCount !== 1) continue;
      
                  const contentBounds = getContentBounds(item);
                  const w = Math.round(contentBounds.width);
                  const h = Math.round(contentBounds.height);
      
                  // アイコン判定 (カラーまたは救済された単体オブジェクトで、一定サイズ以上のもの)
                  const isTargetForIconCheck = (!item.isBlackish) || (item.isRescuedDarkGroup);
                  
                  if (isTargetForIconCheck && ocrOriginalImage) {
                      const wRatio = w / ocrOriginalImage.width;
                      const hRatio = h / ocrOriginalImage.height;
                      
                      if (wRatio >= 0.01 && hRatio >= 0.02) {
                          stripVisualAids(item, `アイコン判定 (単体 / W:${(wRatio*100).toFixed(1)}% H:${(hRatio*100).toFixed(1)}%)`);
                          continue;
                      }
                  }
      
                  // ★修正: サイズ閾値を 140 に設定
                  if (w >= 140 || h >= 140) {
                      stripVisualAids(item, `サイズ超過 (W:${w}px, H:${h}px) - イラスト判定`);
                      continue;
                  }
      
                  if (w >= 10 && h >= 10) continue;
      
                  const rect1 = item.originalRect;
                  let isContained = false;
                  const cx = rect1.x + rect1.w / 2;
                  const cy = rect1.y + rect1.h / 2;
      
                  for (const other of allCandidates) {
                      if (item === other) continue;
                      if (other.isBackground) continue;
                      if (other._isOcrCandidate !== true) continue;
                      
                      const rect2 = other.originalRect;
                      if (!rect2) continue;
                      if (rect2.w <= rect1.w || rect2.h <= rect1.h) continue;
                      
                      if (cx >= rect2.x && cx <= rect2.x + rect2.w &&
                          cy >= rect2.y && cy <= rect2.y + rect2.h) {
                          isContained = true;
                          break;
                      }
                  }
      
                  if (isContained) {
                      stripVisualAids(item, `微細ノイズ (他要素に内包 / W:${Math.round(rect1.w)}px H:${Math.round(rect1.h)}px)`);
                  }
              }
          };
      
          filterGroups(blackGroups);
          filterGroups(colorGroups);
      
          all = []; // グローバル変数を更新
          all.push(...others);
          all.push(...blackGroups);
          all.push(...colorGroups);
          
          all.sort((a,b) => {
              if (a.isBackground) return -1;
              if (b.isBackground) return 1;
              
              // ★修正: デバッグ用の線や枠(VisualFrame)は最前面に表示する
              const aIsVisual = !!a.isVisualFrame;
              const bIsVisual = !!b.isVisualFrame;
              
              if (aIsVisual && !bIsVisual) return 1;  // aが枠なら手前
              if (!aIsVisual && bIsVisual) return -1; // bが枠なら手前

              // それ以外（文字同士など）はOCR候補順
              const aIsText = a._isOcrCandidate === true;
              const bIsText = b._isOcrCandidate === true;
              if (aIsText && !bIsText) return 1;
              if (!aIsText && bIsText) return -1;
              return 0;
          });
      
          all.forEach(obj => {
              canvas.add(obj);
              if(obj.isBackground) canvas.sendToBack(obj);
          });
          
          canvas.renderAll();
      }

      
      // ==========================================
      // 関数2: ペアごとの結合判定プロセス (可視化付き・縦理由除外版)
      // ==========================================
      function groupingProcess(targets, uf, debugOutputList) {
          if(targets.length===0) return;
          const limitH = targets[0].originalImageHeight * 0.15;
          const groupBounds = new Map();
          targets.forEach(o => groupBounds.set(o, {minY: o.originalRect.y, maxY: o.originalRect.y + o.originalRect.h}));
      
          // キャンバス上のスケーリングされた表示座標ではなく、常に 100% 正確な等倍生ピクセル座標 (originalRect) を使用する！
          const getRect = (o) => ({
              l: o.originalRect.x, t: o.originalRect.y,
              r: o.originalRect.x + o.originalRect.w, b: o.originalRect.y + o.originalRect.h
          });
          const isContained = (inR, outR) => (inR.l >= outR.l && inR.t >= outR.t && inR.r <= outR.r && inR.b <= outR.b);
      
          for(let i=0; i<targets.length; i++) {
              for(let j=i+1; j<targets.length; j++) {
                  const o1 = targets[i], o2 = targets[j];
                  
                  const r1_rect = getRect(o1);
                  const r2_rect = getRect(o2);
                  
                  // 包含関係にあるものは無視 (等倍整数値のため 100% 決定的に機能する)
                  if(isContained(r1_rect, r2_rect) || isContained(r2_rect, r1_rect)) continue;
      
                  // 中心座標も originalRect から等倍生ピクセルスケールで正確に算出！
                  const c1 = { x: o1.originalRect.x + o1.originalRect.w / 2, y: o1.originalRect.y + o1.originalRect.h / 2 };
                  const c2 = { x: o2.originalRect.x + o2.originalRect.w / 2, y: o2.originalRect.y + o2.originalRect.h / 2 };
                  
                  const h1 = o1.originalRect.h, h2 = o2.originalRect.h;
                  const avgH = (h1+h2)/2;
                  
                  const yDiff = Math.abs(c1.y - c2.y);
                  const xDist = Math.abs(c1.x - c2.x);
                  const wSum = (o1.originalRect.w + o2.originalRect.w)/2;
                  
                  // --- 各条件の判定 ---
                  
                  // 1. 縦位置チェック (高さの0.8倍以内)
// 1. 縦位置チェック (最大 0.8倍まで)
const isV = yDiff < avgH * 0.8;

// 2. 横距離チェック (一律 avgH * 2.0 に固定し、過剰結合を防ぐ)
const maxHDist = avgH * 2.0;
const gap = Math.max(0, xDist - wSum);
const isH = gap <= maxHDist;

// 3. 色チェック (色差 60未満に統一)
const colorDist = Math.sqrt((o1.avgColor.r-o2.avgColor.r)**2 + (o1.avgColor.g-o2.avgColor.g)**2 + (o1.avgColor.b-o2.avgColor.b)**2);
const isColorOk = colorDist < 60;
      
                  // 4. 幾何学的一貫性チェック (暗色救済オブジェクト用)
                  let isGeometryConsistent = true;
                  
                  const isPerfectlyAligned = yDiff < (Math.max(h1, h2) * 0.3);
                  const isTopAligned = Math.abs(r1_rect.t - r2_rect.t) < (Math.max(h1, h2) * 0.2);
                  const isBottomAligned = Math.abs(r1_rect.b - r2_rect.b) < (Math.max(h1, h2) * 0.2);
                  const isHeightSimilar = Math.max(h1, h2) / Math.min(h1, h2) <= 1.8;
                  if (!isHeightSimilar && !isPerfectlyAligned && !isBottomAligned && !isTopAligned) {
                      isGeometryConsistent = false;
                  }
                  const isRescued1 = o1.isRescuedDark;
                  const isRescued2 = o2.isRescuedDark;

                  // デバッグログ表示用の範囲判定も等倍座標で行う
                  const o1InBox = o1.originalRect.x > 2100 && o1.originalRect.x < 2700 && o1.originalRect.y > 1250 && o1.originalRect.y < 1350;
                  const o2InBox = o2.originalRect.x > 2100 && o2.originalRect.x < 2700 && o2.originalRect.y > 1250 && o2.originalRect.y < 1350;
                  if (o1InBox && o2InBox) {
                      console.log("[DEBUG-GROUPING-AI-GOV]", {
                          o1: { x: o1.originalRect.x, y: o1.originalRect.y, w: o1.originalRect.w, h: o1.originalRect.h, color: o1.avgColor, isRescued: o1.isRescuedDark },
                          o2: { x: o2.originalRect.x, y: o2.originalRect.y, w: o2.originalRect.w, h: o2.originalRect.h, color: o2.avgColor, isRescued: o2.isRescuedDark },
                          isV: isV, yDiff: yDiff, avgH_08: avgH * 0.8,
                          isH: isH, xDist: xDist, wSum: wSum, limitH_2: avgH * 2.0,
                          isColorOk: isColorOk, colorDist: colorDist,
                          isGeometryConsistent: isGeometryConsistent
                      });
                  }
      
                  if (isRescued1 !== isRescued2) {
                      let baseObj, rescuedObj;
                      if (isRescued1) { rescuedObj = o1; baseObj = o2; } 
                      else { rescuedObj = o2; baseObj = o1; }
      
                      const baseH = baseObj.originalRect.h;
                      const baseTop = baseObj.originalRect.y;
                      const baseBottom = baseObj.originalRect.y + baseH;
                      
                      const rescuedH = rescuedObj.originalRect.h;
                      const rescuedTop = rescuedObj.originalRect.y;
                      const rescuedBottom = rescuedObj.originalRect.y + rescuedH;
      
                      const tolerance = baseH * 0.2;
                      const isTopOk = rescuedTop >= (baseTop - tolerance);
                      const isBottomOk = rescuedBottom <= (baseBottom + tolerance);
      
                      if (!isTopOk || !isBottomOk) {
                          isGeometryConsistent = false;
                      }
                  }
      
                  // --- 判定結果による処理 ---
      
                  // Y軸方向の完全包含チェック (句読点や小さい文字が、大きい文字のY範囲内に完全に収まっているか)
const isVerticallyContained = (r1_rect.t >= r2_rect.t && r1_rect.b <= r2_rect.b) || (r2_rect.t >= r1_rect.t && r2_rect.b <= r1_rect.b);

// 追加: 段組の境界などで、水平に隙間がありつつ垂直にも大きくズレている場合は結合しない
// ただし、小さい文字が垂直方向に完全に包含されている場合（句読点など）は段組のズレとみなさない
const isColumnStaggered = (gap > avgH * 0.5) && (yDiff > avgH * 0.3) && !isVerticallyContained;

if(isV && isH && isColorOk && isGeometryConsistent && !isColumnStaggered) {
                      // >> 結合成功の場合
                      const r1 = uf.find(o1), r2 = uf.find(o2);
                      if(r1 !== r2) {
                          const b1 = groupBounds.get(r1), b2 = groupBounds.get(r2);
                          const newH = Math.max(b1.maxY, b2.maxY) - Math.min(b1.minY, b2.minY);
                          
                          // ★変更: 平均高さの3.5倍の動的制限を追加して異なる行の過剰結合を防ぐ
if(newH <= limitH) {
                              uf.union(o1, o2);
                              const newRoot = uf.find(o1);
                              groupBounds.set(newRoot, {minY: Math.min(b1.minY, b2.minY), maxY: Math.max(b1.maxY, b2.maxY)});
                          }
                      }
                  } 
                  else if (isH && debugOutputList) {
                      // >> ★結合失敗の場合の可視化 (キャンバスの描画座標にスケールして線などを引く)
                      let failColor = null;
                      let failReason = "";
      
                      if (isV) {
                          if (!isColorOk) {
                              failColor = '#f59e0b'; // オレンジ (色違い)
                              failReason = "色";
                          } else if (!isGeometryConsistent) {
                              failColor = '#a855f7'; // 紫 (幾何学不整合)
                              failReason = "形";
                          }
                      }
      
                      if (failColor) {
                          // 表示用にキャンバスのスケールを算出
                          let scaleFactor = 1.0;
                          if (ocrOriginalImage && ocrOriginalImage.naturalWidth) {
                              scaleFactor = canvas.width / ocrOriginalImage.naturalWidth;
                          } else if (typeof origW !== 'undefined') {
                              scaleFactor = canvas.width / origW;
                          }
                          
                          const c1_canvas = o1.getCenterPoint();
                          const c2_canvas = o2.getCenterPoint();
                          
                          // 線を描画
                          const line = new fabric.Line([c1_canvas.x, c1_canvas.y, c2_canvas.x, c2_canvas.y], {
                              stroke: failColor,
                              strokeWidth: 1,
                              strokeDashArray: [2, 2], // 点線
                              opacity: 0.6,
                              originX: 'center', originY: 'center',
                              selectable: false, evented: false,
                              isVisualFrame: true
                          });
                          
                          // 理由テキストを描画
                          const midX = (c1_canvas.x + c2_canvas.x) / 2;
                          const midY = (c1_canvas.y + c2_canvas.y) / 2;
                          const cross = new fabric.Text('× ' + failReason, {
                              left: midX, top: midY,
                              fontSize: 10,
                              fill: failColor,
                              backgroundColor: 'rgba(255,255,255,0.8)',
                              originX: 'center', originY: 'center',
                              selectable: false, evented: false,
                              isVisualFrame: true
                          });
      
                          debugOutputList.push(line);
                          debugOutputList.push(cross);
                      }
                  }
              }
          }
      }

       function generateGroups(targets, uf, colorType) {
           const groups = new Map();
           targets.forEach(o => {
               const root = uf.find(o);
               if(!groups.has(root)) groups.set(root, []);
               groups.get(root).push(o);
           });
       
           const resultList = [];
       
           // キャンバス座標ではなく、100% 等倍で正確な originalRect を使用して包含判定を行う！
           const getRect = (o) => ({
               l: o.originalRect.x, t: o.originalRect.y,
               r: o.originalRect.x + o.originalRect.w, b: o.originalRect.y + o.originalRect.h
           });
           const isContained = (inR, outR) => (inR.l >= outR.l && inR.t >= outR.t && inR.r <= outR.r && inR.b <= outR.b);
       
           // 動的な線の太さとフォントサイズを計算
           let dynamicStrokeWidth = 2;
           let dynamicFontSize = 14;
           
           if (ocrOriginalImage) {
               const maxDim = Math.max(ocrOriginalImage.width, ocrOriginalImage.height);
               dynamicStrokeWidth = Math.max(2, Math.floor(maxDim * 0.003));
               dynamicFontSize = Math.max(14, dynamicStrokeWidth * 6);
           }
       
           // グループ作成・追加 of 共通関数
           const createAndPushGroup = (membersList, isRescuedObjOverride = null) => {
               if (membersList.length === 0) return;
       
               let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
               const hasRescuedObj = (isRescuedObjOverride !== null) 
                                     ? isRescuedObjOverride 
                                     : membersList.some(m => m.isRescuedDark);
       
               const contentMembers = membersList.filter(m => !m.isVisualFrame);
               
               // --- ★追加: 視覚的なつながり線と中心点を生成 ---
               if (contentMembers.length > 0) {
                   // 左から右へ並べ替え
                   contentMembers.sort((a, b) => a.originalRect.x - b.originalRect.x);
       
                   // 1. 中心同士をつなぐ線を描画
                   for (let i = 0; i < contentMembers.length - 1; i++) {
                       const c1_canvas = contentMembers[i].getCenterPoint();
                       const c2_canvas = contentMembers[i+1].getCenterPoint();
       
                       const connectionLine = new fabric.Line([c1_canvas.x, c1_canvas.y, c2_canvas.x, c2_canvas.y], {
                           stroke: '#06b6d4', 
                           strokeWidth: 1,
                           strokeDashArray: [3, 3], 
                           originX: 'center', originY: 'center',
                           selectable: false, evented: false,
                           isVisualFrame: true 
                       });
                       membersList.push(connectionLine);
                   }
       
                   // 2. 中心点(●)を描画
                   contentMembers.forEach(m => {
                       const c_canvas = m.getCenterPoint();
                       const centerDot = new fabric.Circle({
                           left: c_canvas.x, top: c_canvas.y,
                           radius: Math.max(2, dynamicStrokeWidth), 
                           fill: '#06b6d4', 
                           originX: 'center', originY: 'center',
                           selectable: false, evented: false,
                           isVisualFrame: true 
                       });
                       membersList.push(centerDot);
                   });
               }
       
               // 3. 内部空隙警告星印の判定 (等倍座標系で正確に処理)
               if (contentMembers.length >= 2) {
                   const totalH = contentMembers.reduce((sum, m) => sum + m.originalRect.h, 0);
                   const avgH = totalH / contentMembers.length;
       
                   // キャンバスのスケールを算出
                   let scaleFactor = 1.0;
                   if (ocrOriginalImage && ocrOriginalImage.naturalWidth) {
                       scaleFactor = canvas.width / ocrOriginalImage.naturalWidth;
                   } else if (typeof origW !== 'undefined') {
                       scaleFactor = canvas.width / origW;
                   }
       
                   for (let i = 0; i < contentMembers.length - 1; i++) {
                       const curr = contentMembers[i];
                       const next = contentMembers[i+1];
                       const currRight = curr.originalRect.x + curr.originalRect.w;
                       const nextLeft = next.originalRect.x;
                       const gap = nextLeft - currRight;
       
                       if (gap >= avgH) {
                           const currTop = curr.originalRect.y;
                           const nextTop = next.originalRect.y;
                           const minTop = Math.min(currTop, nextTop);
       
                           // 星印を表示用のキャンバス座標系に変換して配置
                           const star = new fabric.Text('★', {
                               left: (currRight + gap / 2) * scaleFactor,
                               top: minTop * scaleFactor - (dynamicStrokeWidth * 2),
                               fontSize: dynamicFontSize,
                               fill: '#fbbf24', 
                               stroke: '#b45309',
                               strokeWidth: Math.max(1, dynamicStrokeWidth / 4),
                               originX: 'center', 
                               originY: 'bottom',
                               selectable: false, evented: false,
                               isVisualFrame: true, isStar: true,
                               starReason: `内部空隙警告: 幅${Math.round(gap)}px (基準高さ${Math.round(avgH)}px)`
                           });
                           membersList.push(star);
                       }
                   }
               }
       
               // Bounding box を等倍座標（originalRect）から算出
               contentMembers.forEach(o => {
                   const oL = o.originalRect.x;
                   const oT = o.originalRect.y;
                   if (oL < gMinX) gMinX = oL;
                   if (oT < gMinY) gMinY = oT;
                   if (oL + o.originalRect.w > gMaxX) gMaxX = oL + o.originalRect.w;
                   if (oT + o.originalRect.h > gMaxY) gMaxY = oT + o.originalRect.h;
               });
               
               const gW_original = gMaxX - gMinX;
               const gH_original = gMaxY - gMinY;
       
               // キャンバス上の表示用サイズと位置に変換
               let scaleFactor = 1.0;
               if (ocrOriginalImage && ocrOriginalImage.naturalWidth) {
                   scaleFactor = canvas.width / ocrOriginalImage.naturalWidth;
               } else if (typeof origW !== 'undefined') {
                   scaleFactor = canvas.width / origW;
               }
       
               const gW = gW_original * scaleFactor;
               const gH = gH_original * scaleFactor;
               const gCX = (gMinX + gW_original / 2) * scaleFactor;
               const gCY = (gMinY + gH_original / 2) * scaleFactor;
       
               let strokeColor;
               if (hasRescuedObj) {
                   strokeColor = '#059669'; 
               } else {
                   strokeColor = colorType === 'red' ? 'red' : 'blue';
               }
       
               const frame = new fabric.Rect({
                   left: gCX, top: gCY,
                   width: gW, height: gH, 
                   originX: 'center', originY: 'center',
                   fill: 'transparent',
                   stroke: strokeColor,
                   strokeWidth: hasRescuedObj ? (dynamicStrokeWidth * 1.5) : dynamicStrokeWidth,
                   selectable: false, evented: false,
                   isVisualFrame: true
               });
               membersList.push(frame);
       
               const group = new fabric.Group(membersList, {
                   originX: 'center', originY: 'center',
                   subTargetCheck: true,
                   interactive: true,
                   fill: 'transparent',
                   hasControls: false,
                   hasBorders: false,
                   lockMovementX: true,
                   lockMovementY: true,
                   hoverCursor: 'pointer',
                   objectCaching: false 
               });
       
               group._isOcrCandidate = true;
               group.isBlackish = (colorType === 'red');
               group.isColorChar = (colorType === 'blue');
               group.isRescuedDarkGroup = hasRescuedObj;
       
               const firstContent = contentMembers[0] || membersList[0];
               group.inclusionReason = firstContent ? firstContent.inclusionReason : "条件合致"; 
               group._originalMemberCount = contentMembers.length; 
               
               // グループ自体の originalRect も等倍ピクセル空間で正確に代数合算！
               group.originalRect = {
                   x: gMinX,
                   y: gMinY,
                   w: gW_original,
                   h: gH_original
               };
               
               resultList.push(group);
           };
       
           groups.forEach(originalMembers => {
               let members = [...originalMembers];
       
               // カラー文字判定における微小ノイズ除外 (等倍座標系で正確に処理)
               if(colorType === 'blue' && members.length <= 2) {
                   let maxH = 0;
                   let maxW = 0;
                   members.forEach(o => {
                       const h = o.originalRect.h;
                       const w = o.originalRect.w;
                       if(h > maxH) maxH = h;
                       if(w > maxW) maxW = w;
                   });
       
                   if (maxH < 15 && maxW < 15) {
                       members.forEach(o => { 
                           o.isColorChar = false; 
                           o.exclusionReason = `[カラー判定] 構成数・サイズ不足 (構成:${members.length}個<=2 / 最大:${Math.round(maxW)}x${Math.round(maxH)} < 基準15px)`; 
                           resultList.push(o); 
                       });
                       return;
                   }
               }
       
               let primaryOutliers = [];
               let containedOutliers = [];
               let normalMembers = [...members];
               let hasSeparation = false;
       
               // 黒文字の突出文字（星印）分離ロジック (等倍座標系で正確に処理)
               if (colorType === 'red' && members.length > 1) {
                   let totalH = 0;
                   members.forEach(o => {
                       totalH += o.originalRect.h;
                   });
                   const avgH = totalH / members.length;
                   const starThreshold = avgH * 2.5;
       
                   primaryOutliers = members.filter(o => o.originalRect.h >= starThreshold);
       
                   if (primaryOutliers.length > 0) {
                       hasSeparation = true;
                       normalMembers = []; 
       
                       members.forEach(o => {
                           if (primaryOutliers.includes(o)) return;
                           let isContainedInParent = false;
                           const oRect = getRect(o);
                           for (const parent of primaryOutliers) {
                               const pRect = getRect(parent);
                               if (isContained(oRect, pRect)) {
                                   isContainedInParent = true;
                                   break;
                               }
                           }
                           if (isContainedInParent) {
                               containedOutliers.push(o);
                           } else {
                               normalMembers.push(o);
                           }
                       });
                   }
               }
       
               // 左端位置ズレによる分離判定 (等倍座標系で正確に処理)
               if (normalMembers.length >= 2) {
                   normalMembers.sort((a, b) => a.originalRect.x - b.originalRect.x);
                   const leftmost = normalMembers[0];
                   const rest = normalMembers.slice(1);
                   const leftmostRight = leftmost.originalRect.x + leftmost.originalRect.w;
                   
                   let restMinLeft = Infinity;
                   rest.forEach(m => {
                       const l = m.originalRect.x;
                       if (l < restMinLeft) restMinLeft = l;
                   });
                   const gap = restMinLeft - leftmostRight;
                   const leftmostCY = leftmost.originalRect.y + leftmost.originalRect.h / 2;
       
                   let rMinY = Infinity, rMaxY = -Infinity;
                   let restTotalH = 0;
                   rest.forEach(m => {
                       const h = m.originalRect.h;
                       const t = m.originalRect.y;
                       const b = m.originalRect.y + h;
                       if(t < rMinY) rMinY = t;
                       if(b > rMaxY) rMaxY = b;
                       restTotalH += h;
                   });
                   const restCY = (rMinY + rMaxY) / 2;
                   const restAvgH = restTotalH / rest.length;
                   const yDiff = Math.abs(leftmostCY - restCY);
       
                   if (gap > 2 && yDiff > (restAvgH * 0.5)) {
                       // 表示用にキャンバスのスケールを算出
                       let scaleFactor = 1.0;
                       if (ocrOriginalImage && ocrOriginalImage.naturalWidth) {
                           scaleFactor = canvas.width / ocrOriginalImage.naturalWidth;
                       } else if (typeof origW !== 'undefined') {
                           scaleFactor = canvas.width / origW;
                       }
                       
                       const star = new fabric.Text('★', {
                           left: leftmost.originalRect.x * scaleFactor, 
                           top: leftmost.originalRect.y * scaleFactor - (dynamicStrokeWidth * 2),
                           fontSize: dynamicFontSize,
                           fill: '#fbbf24', 
                           stroke: '#b45309',
                           strokeWidth: Math.max(1, dynamicStrokeWidth / 4),
                           originX: 'left', 
                           originY: 'bottom',
                           selectable: false, evented: false,
                           isVisualFrame: true, isStar: true,
                           starReason: `左端位置ズレにより分離: Y差${Math.round(yDiff)}px (基準${Math.round(restAvgH*0.5)}px)`
                       });
                       
                       createAndPushGroup([leftmost, star], leftmost.isRescuedDark);
                       normalMembers.shift(); 
                       hasSeparation = true;
                   }
               }
       
               primaryOutliers.forEach(o => {
                   const currentH = o.originalRect.h;
                   const totalH = members.reduce((sum, m) => sum + m.originalRect.h, 0);
                   const avgH = totalH / members.length;
       
                   // 表示用にキャンバスのスケールを算出
                   let scaleFactor = 1.0;
                   if (ocrOriginalImage && ocrOriginalImage.naturalWidth) {
                       scaleFactor = canvas.width / ocrOriginalImage.naturalWidth;
                   } else if (typeof origW !== 'undefined') {
                       scaleFactor = canvas.width / origW;
                   }
       
                   const star = new fabric.Text('★', {
                       left: o.originalRect.x * scaleFactor, 
                       top: o.originalRect.y * scaleFactor - (dynamicStrokeWidth * 2),
                       fontSize: dynamicFontSize,
                       fill: '#fbbf24', 
                       stroke: '#b45309',
                       strokeWidth: Math.max(1, dynamicStrokeWidth / 4),
                       originX: 'left', 
                       originY: 'bottom',
                       selectable: false, evented: false,
                       isVisualFrame: true, isStar: true,
                       starReason: `高さ突出により分離: ${Math.round(currentH)}px (平均${Math.round(avgH)}pxの${(currentH/avgH).toFixed(1)}倍)`
                   });
                   createAndPushGroup([o, star], o.isRescuedDark);
               });
       
               containedOutliers.forEach(o => {
                   o.inclusionReason += " (突出オブジェクト内包により分離)";
                   createAndPushGroup([o], o.isRescuedDark);
               });
       
               if (normalMembers.length > 0) {
                   if (hasSeparation && normalMembers.length > 1) {
                       const subUf = new UnionFind(normalMembers);
                       groupingProcess(normalMembers, subUf); 
                       const subGroupsMap = new Map();
                       normalMembers.forEach(o => {
                           const root = subUf.find(o);
                           if(!subGroupsMap.has(root)) subGroupsMap.set(root, []);
                           subGroupsMap.get(root).push(o);
                       });
                       subGroupsMap.forEach(subMembers => {
                           createAndPushGroup(subMembers);
                       });
                   } else {
                       createAndPushGroup(normalMembers);
                   }
               }
           });
           return resultList;
       }
       
function stripVisualAids(groupOrObj, reason) {
          groupOrObj._isOcrCandidate = false;
          groupOrObj.isBlackish = false;
          groupOrObj.isColorChar = false;
          groupOrObj.exclusionReason = reason;
          
          if(groupOrObj.type === 'group') {
              groupOrObj.getObjects().forEach(o => { if(!o) return;
                  o.exclusionReason = reason;
                  // ★修正: 枠線や星(isVisualFrame)を確実に非表示にする
                  if (o.isVisualFrame || o.isStar) {
                      o.visible = false;
                      o.opacity = 0; // 透明度も0にして完全に隠す
                  }
              });
              // ★追加: 描画更新を確実に行う
              groupOrObj.dirty = true;
              groupOrObj.addWithUpdate();
              canvas.requestRenderAll();
          }
      }
      
      // ★修正: 分割後も画像サイズに合った枠線の太さを適用する版
      async function splitGroupsAtStarsAndRefactor(groups) {
          const newGroupsToAdd = [];
      
          // 処理中は描画を止める
          canvas.discardActiveObject();
          
          // ★追加: 線の太さ計算
          let dynamicStrokeWidth = 2;
          if (ocrOriginalImage) {
              const maxDim = Math.max(ocrOriginalImage.width, ocrOriginalImage.height);
              dynamicStrokeWidth = Math.max(2, Math.floor(maxDim * 0.003));
          }
          
          for (const group of groups) {
              const allObjects = group.getObjects();
              
              const contentMembers = allObjects.filter(o => !o.isVisualFrame && !o.isStar);
              const starMembers = allObjects.filter(o => o.isStar && o.visible && o.starReason.startsWith("内部空隙警告"));
              
              if (contentMembers.length < 2 || starMembers.length === 0) continue;
      
              const groupMatrix = group.calcTransformMatrix();
              
              const splitXPositions = starMembers.map(star => {
                  const centerPoint = fabric.util.transformPoint({ x: star.left, y: star.top }, groupMatrix);
                  return centerPoint.x;
              }).sort((a, b) => a - b);
      
              const restoredMembers = contentMembers.map(o => {
                  // originalRect から直接、画像等倍絶対座標のleft/top中心とサイズを復元！
                  // (Fabricの groupMatrix 破綻バグを100%回避するスマートなアプローチ)
                  const oRect = o.originalRect || { x: o.left, y: o.top, w: o.width || 1, h: o.height || 1 };
                  const absLeft = oRect.x + oRect.w / 2;
                  const absTop = oRect.y + oRect.h / 2;
                  
                  return new Promise(resolve => {
                      o.clone(cloned => {
                          cloned.set({
                              left: absLeft,
                              top: absTop,
                              scaleX: 1.0,
                              scaleY: 1.0,
                              originX: 'center',
                              originY: 'center',
                              isBlackish: group.isBlackish,
                              isColorChar: group.isColorChar,
                              isRescuedDark: o.isRescuedDark || false,
                              originalRect: o.originalRect,
                              originalImageHeight: o.originalImageHeight,
                              pixelCount: o.pixelCount,
                              avgColor: o.avgColor,
                              _isOcrCandidate: true,
                              detectedText: "", 
                              rawOcrText: ""
                          });
                          resolve(cloned);
                      });
                  });
              });
      
              const resolvedMembers = await Promise.all(restoredMembers);
              const buckets = Array.from({ length: splitXPositions.length + 1 }, () => []);
              
              resolvedMembers.forEach(member => {
                  const mx = member.left;
                  let placedIndex = 0;
                  for (let i = 0; i < splitXPositions.length; i++) {
                      if (mx < splitXPositions[i]) {
                          placedIndex = i;
                          break;
                      }
                      placedIndex = i + 1;
                  }
                  buckets[placedIndex].push(member);
              });
      
              buckets.forEach(bucketMembers => {
                  if (bucketMembers.length === 0) return;
                  
                  let newTarget;
                  
                  // メンバー数に関わらず、常に fabric.Group として統一的に管理・ラップする！
                  // (これにより type=image のOCR候補混入によるエクスポート時スキップバグを完璧に解消)
                  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                  bucketMembers.forEach(m => {
                      const w = m.width * m.scaleX;
                      const h = m.height * m.scaleY;
                      const l = m.left - w/2;
                      const t = m.top - h/2;
                      if (l < minX) minX = l;
                      if (t < minY) minY = t;
                      if (l + w > maxX) maxX = l + w;
                      if (t + h > maxY) maxY = t + h;
                  });
                  
                  const gW = maxX - minX;
                  const gH = maxY - minY;
                  const gCX = minX + gW / 2;
                  const gCY = minY + gH / 2;
                  
                  const strokeColor = group.isBlackish ? 'red' : 'blue';
                  
                  const frame = new fabric.Rect({
                      left: gCX, top: gCY,
                      width: gW, height: gH,
                      originX: 'center', originY: 'center',
                      fill: 'transparent',
                      stroke: strokeColor,
                      strokeWidth: dynamicStrokeWidth,
                      selectable: false, evented: false,
                      isVisualFrame: true
                  });
                  
                  newTarget = new fabric.Group([...bucketMembers, frame], {
                      originX: 'center', originY: 'center',
                      subTargetCheck: true,
                      interactive: true,
                      fill: 'transparent',
                      hasControls: false,
                      hasBorders: false,
                      lockMovementX: true,
                      lockMovementY: true,
                      hoverCursor: 'pointer',
                      objectCaching: false
                  });
                  
                  newTarget._originalMemberCount = bucketMembers.length;
                  
                  newTarget._isOcrCandidate = true;
                  newTarget.isBlackish = group.isBlackish;
                  newTarget.isColorChar = group.isColorChar;
                  newTarget.isRescuedDarkGroup = bucketMembers.some(m => m.isRescuedDark);
                  newTarget.inclusionReason = (group.inclusionReason || "") + "<br><span class='text-[9px] text-purple-500'>※空隙分割により再生成</span>";
      
                  newGroupsToAdd.push(newTarget);
              });
      
              canvas.remove(group);
              const idx = all.indexOf(group);
              if (idx > -1) all.splice(idx, 1);
          }
      
          newGroupsToAdd.forEach(newObj => {
              canvas.add(newObj);
              all.push(newObj);
          });
          
          canvas.renderAll();
          
          return newGroupsToAdd;
      }      
      
// ★修正: 重なっているOCR候補を結合して再OCRをかける関数 (Infinityエラー修正版)
      async function checkAndMergeOverlaps(objects) {
          const candidates = objects.filter(o => o._isOcrCandidate && o.visible);
          if (candidates.length < 2) return [];

          const mergedGroups = [];
          const processed = new Set();

          // 座標計算用ヘルパー（等倍生座標 originalRect を最優先に使用！）
          const getBounds = (o) => {
              if (o.originalRect) {
                  return {
                      l: o.originalRect.x,
                      t: o.originalRect.y,
                      r: o.originalRect.x + o.originalRect.w,
                      b: o.originalRect.y + o.originalRect.h,
                      w: o.originalRect.w,
                      h: o.originalRect.h,
                      obj: o
                  };
              }
              const w = o.width * (o.scaleX || 1);
              const h = o.height * (o.scaleY || 1);
              return { 
                  l: o.left - w/2, 
                  t: o.top - h/2, 
                  r: o.left + w/2, 
                  b: o.top + h/2,
                  w: w, h: h,
                  obj: o 
              };
          };

          let boxes = candidates.map(getBounds);

          for (let i = 0; i < boxes.length; i++) {
              if (processed.has(boxes[i].obj)) continue;

              let currentRect = { ...boxes[i] };
              let members = [boxes[i].obj];
              processed.add(boxes[i].obj);

              let changed = true;
              while(changed) {
                  changed = false;
                  for (let j = i + 1; j < boxes.length; j++) {
                      if (processed.has(boxes[j].obj)) continue;

                      const b2 = boxes[j];
                      const margin = 5; 
                      const isOverlapping = !(
                          b2.l > currentRect.r - margin || 
                          b2.r < currentRect.l + margin || 
                          b2.t > currentRect.b - margin || 
                          b2.b < currentRect.t + margin
                      );

                      if (isOverlapping) {
                          currentRect.l = Math.min(currentRect.l, b2.l);
                          currentRect.t = Math.min(currentRect.t, b2.t);
                          currentRect.r = Math.max(currentRect.r, b2.r);
                          currentRect.b = Math.max(currentRect.b, b2.b);
                          
                          members.push(b2.obj);
                          processed.add(b2.obj);
                          changed = true; 
                      }
                  }
              }

              if (members.length > 1) {
                  const newW = currentRect.r - currentRect.l;
                  const newH = currentRect.b - currentRect.t;
                  const newCX = currentRect.l + newW / 2;
                  const newCY = currentRect.t + newH / 2;

                  members.forEach(m => {
                      canvas.remove(m);
                      const idx = all.indexOf(m);
                      if (idx > -1) all.splice(idx, 1);
                  });

                  members.sort((a,b) => (a.top - b.top) || (a.left - b.left));
                  const baseObj = members[0];

                  const strokeColor = baseObj.isBlackish ? 'red' : (baseObj.isColorChar ? 'blue' : '#059669');
                  let strokeW = 2;
                  if (ocrOriginalImage) {
                      const maxDim = Math.max(ocrOriginalImage.width, ocrOriginalImage.height);
                      strokeW = Math.max(2, Math.floor(maxDim * 0.003));
                  }

                  const frame = new fabric.Rect({
                      left: newCX, top: newCY,
                      width: newW, height: newH,
                      originX: 'center', originY: 'center',
                      fill: 'transparent',
                      stroke: strokeColor,
                      strokeWidth: strokeW,
                      selectable: false, evented: false,
                      isVisualFrame: true
                  });

                  const groupContent = await Promise.all(members.map(m => {
                      return new Promise(resolve => {
                          m.clone(cloned => {
                              cloned.visible = false; 
                              resolve(cloned);
                          });
                      });
                  }));

                  const newGroup = new fabric.Group([...groupContent, frame], {
                      left: newCX, top: newCY,
                      originX: 'center', originY: 'center',
                      subTargetCheck: true,
                      interactive: true,
                      fill: 'transparent',
                      hasControls: false,
                      hasBorders: false,
                      lockMovementX: true,
                      lockMovementY: true,
                      hoverCursor: 'pointer',
                      objectCaching: false
                  });

                  newGroup._isOcrCandidate = true;
                  newGroup.isBlackish = baseObj.isBlackish;
                  newGroup.isColorChar = baseObj.isColorChar;
                  newGroup.isRescuedDarkGroup = members.some(m => m.isRescuedDark || m.isRescuedDarkGroup);
                  newGroup.inclusionReason = baseObj.inclusionReason + "<br><span class='text-[9px] text-pink-500'>※重なり結合により再生成</span>";
                  newGroup._originalMemberCount = members.length;
                  
                  // --- ★修正: 座標計算ロジック (Infinity回避の強化) ---
                  let origMinX = Infinity, origMinY = Infinity, origMaxX = -Infinity, origMaxY = -Infinity;
                  
                  members.forEach(m => {
                      // ★ここを修正: グループであっても、それ自体がoriginalRectを持っていれば優先利用する
                      if (m.originalRect) {
                           origMinX = Math.min(origMinX, m.originalRect.x);
                           origMinY = Math.min(origMinY, m.originalRect.y);
                           origMaxX = Math.max(origMaxX, m.originalRect.x + m.originalRect.w);
                           origMaxY = Math.max(origMaxY, m.originalRect.y + m.originalRect.h);
                      } 
                      // originalRectがない場合のみ、中身を探す（古いグループ対策）
                      else if(m.type === 'group') {
                          m.getObjects().forEach(child => { 
                              if(child.originalRect) {
                                  origMinX = Math.min(origMinX, child.originalRect.x);
                                  origMinY = Math.min(origMinY, child.originalRect.y);
                                  origMaxX = Math.max(origMaxX, child.originalRect.x + child.originalRect.w);
                                  origMaxY = Math.max(origMaxY, child.originalRect.y + child.originalRect.h);
                              }
                          });
                      }
                  });
                  
                  // フォールバック: 計算失敗時はキャンバス座標から逆算
                  if (origMinX === Infinity || origMaxX <= origMinX) {
                      if (ocrOriginalImage) {
                          const ratio = ocrOriginalImage.width / canvas.width; 
                          origMinX = currentRect.l * ratio;
                          origMinY = currentRect.t * ratio;
                          origMaxX = currentRect.r * ratio;
                          origMaxY = currentRect.b * ratio;
                      }
                  }

                  // 整数化・境界値チェック
                  if (ocrOriginalImage) {
                      origMinX = Math.max(0, Math.floor(origMinX));
                      origMinY = Math.max(0, Math.floor(origMinY));
                      origMaxX = Math.min(ocrOriginalImage.width, Math.ceil(origMaxX));
                      origMaxY = Math.min(ocrOriginalImage.height, Math.ceil(origMaxY));
                  } else {
                      origMinX = Math.floor(origMinX);
                      origMinY = Math.floor(origMinY);
                      origMaxX = Math.ceil(origMaxX);
                      origMaxY = Math.ceil(origMaxY);
                  }

                  const finalW = origMaxX - origMinX;
                  const finalH = origMaxY - origMinY;

                  if (finalW > 0 && finalH > 0) {
                      newGroup.originalRect = { 
                          x: origMinX, 
                          y: origMinY, 
                          w: finalW, 
                          h: finalH 
                      };
                  } else {
                       newGroup.originalRect = { x:0, y:0, w:10, h:10 };
                  }

                  mergedGroups.push(newGroup);
                  canvas.add(newGroup);
                  all.push(newGroup);
              }
          }

          canvas.renderAll();
          return mergedGroups;
      }
      
      
      // ★追加: 欠落していた関数 handleFile
      // ★修正: 画像読み込み処理 (原寸大維持)
      function handleFile(file) {
          if (!file) return;
          
          // 既存データのリセット
          if (ocrOriginalImage) {
              resetData();
          }
      
          uploadScreen.classList.add('hidden');
          loadingScreen.classList.remove('hidden');
          canvasWrapper.classList.remove('hidden'); 
      
          const reader = new FileReader();
          reader.onload = (event) => {
              const imgObj = new Image();
              imgObj.src = event.target.result;
              imgObj.onload = () => {
                  setTimeout(async () => {
                      canvasWrapper.classList.add('plain-bg');
                      infoPanel.classList.remove('hidden');
                      selectImageBtn.classList.remove('hidden');
                      settingsBtn.classList.remove('hidden');
                      viewControls.classList.remove('hidden'); 
                      
                      ocrOriginalImage = imgObj; 
                      ocrOriginalImage.dominantColor = calculateDominantColor(imgObj);
                      
                      // --- 画像処理開始通知 ---
                      showNotification("画像解析中...", "背景除去とテキスト領域検出を行っています", "processing");
      
                      // 背景色情報の表示
                      const domC = ocrOriginalImage.dominantColor;
                      const bgBrightness = (domC.r + domC.g + domC.b) / 3;
                      const isDarkBg = bgBrightness < 100;
      
                      bgColorChip.style.backgroundColor = `rgb(${domC.r}, ${domC.g}, ${domC.b})`;
                      bgColorChip.title = `背景色: RGB(${domC.r},${domC.g},${domC.b})\n輝度: ${Math.round(bgBrightness)}${isDarkBg ? ' (暗)' : ''}`;
                      bgColorChip.classList.remove('hidden');
                      headerDivider.classList.remove('hidden');
      
                      // 画像スタイル分析と自動設定
                      const analysis = analyzeImageStyle(imgObj);
                      analysisResultDisplay.textContent = analysis.display;
                      analysisResultDisplay.classList.remove('hidden');
      
                      if (analysis.isPhotoLike) {
                          CURRENT_PARAMS.forceBlackText = true;
                          forceBlackTextCheck.checked = true;
                          autoDetectMsg.textContent = "自動設定: 写真調を検出 (黒文字強調ON)";
                          autoDetectMsg.classList.remove('hidden');
                      } else {
                          CURRENT_PARAMS.forceBlackText = false;
                          forceBlackTextCheck.checked = false;
                          autoDetectMsg.textContent = "自動設定: イラスト調を検出 (黒文字強調OFF)";
                          autoDetectMsg.classList.remove('hidden');
                      }
      
                      // ★変更: ここで processImage を呼ぶ前に、キャンバスを原寸大にセット
                      // processImage内部で fitCanvasToImage が呼ばれるが、ここでも明示可能
                      await processImage(imgObj);
                      
                      loadingScreen.classList.add('hidden');
                      canvasWrapper.classList.remove('opacity-0');
                      canvasWrapper.classList.add('fade-in');
                  }, 100);
              }
          };
          reader.readAsDataURL(file);
      }      
      // ★追加: 欠落していた関数 calculateDominantColor
      function calculateDominantColor(img) {
          const canvas = document.createElement('canvas');
          canvas.width = img.width;
          canvas.height = img.height;
          const ctx = canvas.getContext('2d');
          ctx.drawImage(img, 0, 0);
          
          const imageData = ctx.getImageData(0, 0, img.width, img.height);
          const data = imageData.data;
          
          const colorStats = {};
          let maxCount = 0;
          let dominantColor = { r: 255, g: 255, b: 255 };
          const step = 4 * 10; 
          
          for (let i = 0; i < data.length; i += step) {
              const r = data[i];
              const g = data[i+1];
              const b = data[i+2];
              const a = data[i+3];
              
              if (a < 128) continue; 
      
              const qR = Math.floor(r / 10) * 10;
              const qG = Math.floor(g / 10) * 10;
              const qB = Math.floor(b / 10) * 10;
              const key = `${qR},${qG},${qB}`;
              
              if (!colorStats[key]) {
                  colorStats[key] = { count: 0, rSum: 0, gSum: 0, bSum: 0 };
              }
              colorStats[key].count++;
              colorStats[key].rSum += r;
              colorStats[key].gSum += g;
              colorStats[key].bSum += b;
              
              if (colorStats[key].count > maxCount) {
                  maxCount = colorStats[key].count;
              }
          }
          
          for (let key in colorStats) {
              if (colorStats[key].count === maxCount) {
                  const stat = colorStats[key];
                  dominantColor = {
                      r: Math.round(stat.rSum / stat.count),
                      g: Math.round(stat.gSum / stat.count),
                      b: Math.round(stat.bSum / stat.count)
                  };
                  break;
              }
          }
          return dominantColor;
      }
      
      // ★修正: メインキャンバスも絶対配置にして強制的に中央揃えにする版
      function fitCanvasToImage() {
          if (!ocrOriginalImage) return;
      
          const imgW = ocrOriginalImage.width;
          const imgH = ocrOriginalImage.height;
          const wrapperW = canvasWrapper.clientWidth;
          const wrapperH = canvasWrapper.clientHeight;
      
          // 1. キャンバスの論理サイズを画像原寸に設定
          canvas.setDimensions({ width: imgW, height: imgH });
      
          // 2. 画面に収めるための縮小率を計算
          const scale = Math.min(wrapperW / imgW, wrapperH / imgH) * 0.95;
      
          // 3. メインキャンバス（Fabric）の配置設定（★ここを修正）
          // Flexboxレイアウトに頼らず、絶対配置で強制的に中央に持ってくる
          const fabricContainer = canvas.wrapperEl;
          fabricContainer.style.position = 'absolute';
          fabricContainer.style.top = '50%';
          fabricContainer.style.left = '50%';
          fabricContainer.style.transformOrigin = 'center center';
          // 中心を基準に配置し、その上で縮小する
          fabricContainer.style.transform = `translate(-50%, -50%) scale(${scale})`;
          
          // 4. プレビュー用キャンバスの設定（こちらも同様に）
          previewCanvasEl.width = imgW;
          previewCanvasEl.height = imgH;
          previewCanvasEl.style.transformOrigin = 'center center';
          previewCanvasEl.style.transform = `translate(-50%, -50%) scale(${scale})`;
          
          canvas.renderAll();
      }      
      
      // ★追加: 欠落していた関数 showThresholdPreview
      function showThresholdPreview(threshold) {
          if (!ocrOriginalImage) return;
          previewCanvasEl.classList.remove('opacity-0');
          const w = ocrOriginalImage.width;
          const h = ocrOriginalImage.height;
          const pCtx = previewCanvasEl.getContext('2d');
          const tempC = document.createElement('canvas');
          tempC.width = w;
          tempC.height = h;
          const tempCtx = tempC.getContext('2d');
          tempCtx.drawImage(ocrOriginalImage, 0, 0, w, h);
      
          if (CURRENT_PARAMS.sharpenImage) {
              applySharpenFilter(tempCtx, w, h);
          }
      
          const imgData = tempCtx.getImageData(0, 0, w, h);
          const data = imgData.data;
          const domColor = ocrOriginalImage.dominantColor || { r: 255, g: 255, b: 255 };
          console.log("[DEBUG-DOM-COLOR-1] domColor is:", domColor);
          const bgR = domColor.r, bgG = domColor.g, bgB = domColor.b;
      
          for (let i = 0; i < data.length; i += 4) {
              const r = data[i], g = data[i + 1], b = data[i + 2];
              const distance = Math.sqrt((r - bgR) ** 2 + (g - bgG) ** 2 + (b - bgB) ** 2);
              if (distance < threshold) {
                  data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; data[i + 3] = 255; 
              }
          }
          tempCtx.putImageData(imgData, 0, 0);
          pCtx.clearRect(0, 0, previewCanvasEl.width, previewCanvasEl.height);
          pCtx.drawImage(tempC, 0, 0, w, h, 0, 0, previewCanvasEl.width, previewCanvasEl.height);
      }
      
      // ★追加: 欠落していた関数 processImage
      async function processImage(imgElement) {
          canvas.clear();
          canvas.backgroundColor = '#ffffff'; 
          
          fitCanvasToImage();
      
          let w = imgElement.width;
          let h = imgElement.height;
          const totalImagePixels = w * h;
          
          // --- 1. OCR用全体画像の生成 ---
          ocrProcessedCanvasElement = document.createElement('canvas');
          ocrProcessedCanvasElement.width = w;
          ocrProcessedCanvasElement.height = h;
          const ocrCtx = ocrProcessedCanvasElement.getContext('2d');
          ocrCtx.drawImage(imgElement, 0, 0, w, h);
      
          // 設定が「フィルタあり」の場合のみ処理
          if (!CURRENT_PARAMS.useOriginalForOcr) {
              const imgData = ocrCtx.getImageData(0, 0, w, h);
              const data = imgData.data;
              const domColor = ocrOriginalImage.dominantColor || { r: 255, g: 255, b: 255 };
              const bgR = domColor.r, bgG = domColor.g, bgB = domColor.b;
              const tolerance = CURRENT_PARAMS.threshold;
      
              for(let i=0; i<data.length; i+=4) {
                  const r = data[i], g = data[i + 1], b = data[i + 2];
                  const distance = Math.sqrt((r - bgR) ** 2 + (g - bgG) ** 2 + (b - bgB) ** 2);
                  
                  // 背景とみなされる場合は白で塗りつぶす
                  if (distance < tolerance) {
                      data[i]=255; data[i+1]=255; data[i+2]=255; data[i+3]=255;
                  } else {
                      // ★追加: 文字強調処理 (背景除去後)
                      if (CURRENT_PARAMS.forceBlackText) {
                          let isBlackened = false;
                          const brightness = (r + g + b) / 3;
                          if (brightness < 100) {
                              const isReddish = (r > g + 15) && (r > b + 15);
                              const isGreenish = (g > r + 15) && (g > b + 15);
                              if (!isReddish && !isGreenish) {
                                  data[i] = 0; data[i+1] = 0; data[i+2] = 0;
                                  isBlackened = true;
                              }
                          }
                          // 黒化されなかったピクセルも濃度を上げる
                          if (!isBlackened) {
                              data[i] = Math.floor(data[i] * 0.6);
                              data[i+1] = Math.floor(data[i+1] * 0.6);
                              data[i+2] = Math.floor(data[i+2] * 0.6);
                          }
                      }
                  }
              }
              ocrCtx.putImageData(imgData, 0, 0);
              
              // シャープ化
              if (CURRENT_PARAMS.sharpenImage) {
                  applySharpenFilter(ocrCtx, w, h);
              }
          }
          fullOcrImageSrc = ocrProcessedCanvasElement.toDataURL(); // 表示用
      
          // --- 2. 背景除去 & オブジェクト抽出処理 (画面表示用) ---
          const tempCanvas = document.createElement('canvas');
          tempCanvas.width = w;
          tempCanvas.height = h;
          const ctx = tempCanvas.getContext('2d');
          ctx.drawImage(imgElement, 0, 0, w, h);
      
          if (CURRENT_PARAMS.sharpenImage) applySharpenFilter(ctx, w, h);
          
          const imageData = ctx.getImageData(0, 0, w, h);
          const data = imageData.data;
          
          let bgImageData = null;
          if (CURRENT_PARAMS.keepBackground) {
              bgImageData = ctx.createImageData(w, h);
              bgImageData.data.set(ctx.getImageData(0,0,w,h).data);
          }
      
          const domColor = ocrOriginalImage.dominantColor || { r: 255, g: 255, b: 255 };
          console.log("[DEBUG-DOM-COLOR-2] domColor is:", domColor);
          const bgR = domColor.r, bgG = domColor.g, bgB = domColor.b;
          const tolerance = CURRENT_PARAMS.threshold;
          let bgPixelCount = 0;
      
          for (let i = 0; i < data.length; i += 4) {
              const r = data[i], g = data[i + 1], b = data[i + 2];
              const distance = Math.sqrt((r - bgR) ** 2 + (g - bgG) ** 2 + (b - bgB) ** 2);
              if (distance < tolerance) {
                  data[i + 3] = 0; 
                  bgPixelCount++;
              } else {
                  // ★追加: 文字強調処理 (背景除去後)
                  if (CURRENT_PARAMS.forceBlackText) {
                      let isBlackened = false;
                      const brightness = (r + g + b) / 3;
                      if (brightness < 100) {
                          const isReddish = (r > g + 15) && (r > b + 15);
                          const isGreenish = (g > r + 15) && (g > b + 15);
                          if (!isReddish && !isGreenish) {
                              data[i] = 0; data[i+1] = 0; data[i+2] = 0;
                              isBlackened = true;
                          }
                      }
                      // 黒化されなかったピクセルも濃度を上げる
                      if (!isBlackened) {
                          data[i] = Math.floor(data[i] * 0.6);
                          data[i+1] = Math.floor(data[i+1] * 0.6);
                          data[i+2] = Math.floor(data[i+2] * 0.6);
                      }
                  }
              }
          }
          
          const promises = [];
          const bgRatio = bgPixelCount / totalImagePixels;

          // ★追加: デバッグ用の解析画像生成 (背景除去後の画像を白背景で保存)
          if (CURRENT_PARAMS.debugMode) {
              try {
                  const analysisImgData = new ImageData(new Uint8ClampedArray(data), w, h);
                  const tempC = document.createElement('canvas');
                  tempC.width = w;
                  tempC.height = h;
                  tempC.getContext('2d').putImageData(analysisImgData, 0, 0);

                  const analysisCanvas = document.createElement('canvas');
                  analysisCanvas.width = w;
                  analysisCanvas.height = h;
                  const analysisCtx = analysisCanvas.getContext('2d');
                  analysisCtx.fillStyle = '#ffffff';
                  analysisCtx.fillRect(0, 0, w, h);
                  analysisCtx.drawImage(tempC, 0, 0);

                  window._debugAnalysisImageSrc = analysisCanvas.toDataURL();
              } catch (err) {
                  console.warn("[PureEngine] 解析画像の生成に失敗:", err);
                  window._debugAnalysisImageSrc = null;
              }
          }

          // ★変更: カラーチップのタイトルに背景率を追加
          const currentTitle = bgColorChip.title;
          bgColorChip.title = currentTitle + `\n背景率: ${(bgRatio*100).toFixed(1)}%`;
      
          // --- OCR実行判定ロジック ---
          const BG_RATIO_THRESHOLD = 0.40;
          // const BG_BRIGHTNESS_THRESHOLD = 100; // 未使用
      
          const isBgRatioOk = bgRatio >= BG_RATIO_THRESHOLD;
          const shouldRunOcr = isBgRatioOk;
      
          // ヘッダーに判定結果を表示
          ocrDecisionDisplay.classList.remove('hidden');
          if (shouldRunOcr) {
              ocrDecisionDisplay.textContent = "OCR: 実行";
              ocrDecisionDisplay.className = "text-[10px] sm:text-xs font-bold px-2 py-1 rounded bg-green-100 text-green-700 border border-green-200 whitespace-nowrap";
              ocrDecisionDisplay.title = "条件クリア (背景率OK)";
          } else {
              const reasons = [];
              if (!isBgRatioOk) reasons.push(`背景率不足(${(bgRatio*100).toFixed(0)}% < 40%)`);
              
              ocrDecisionDisplay.textContent = `OCR: スキップ`;
              ocrDecisionDisplay.className = "text-[10px] sm:text-xs font-bold px-2 py-1 rounded bg-orange-100 text-orange-700 border border-orange-200 whitespace-nowrap";
              ocrDecisionDisplay.title = `スキップ理由: ${reasons.join(', ')}`;
          }
      
          if (bgRatio < BG_RATIO_THRESHOLD && !CURRENT_PARAMS.forceOcr) {
              ctx.drawImage(imgElement, 0, 0, w, h);
              if (CURRENT_PARAMS.sharpenImage) applySharpenFilter(ctx, w, h);
              const originalData = ctx.getImageData(0, 0, w, h);
              const dummyBlob = { minX:0, minY:0, maxX:w, maxY:h, isFullImage:true, imageData:originalData };
              promises.push(createFabricObjectFromBlob(dummyBlob, w, h, totalImagePixels).then(obj => {
                  obj._isOcrCandidate = false;
                  obj.isBlackish = false;
                  obj.isColorChar = false;
                  obj.exclusionReason = `背景割合低 (${(bgRatio*100).toFixed(1)}%) - 写真/全体画像判定`;
                  return obj;
              }));

          } else if (CURRENT_PARAMS.forceOcr) {
              // 強制OCRモード: 領域全体を1つのテキスト候補として扱う
              // （ブロブ分割すると文字の断片を個別OCRしてしまい誤認識（例: "Ol"）が起きるため、
              //   ユーザー指定の領域は丸ごとTesseractに渡して全文として認識させる）
              ctx.putImageData(imageData, 0, 0);
              const dummyBlob = { minX:0, minY:0, maxX:w, maxY:h, isFullImage:true, imageData:imageData };
              promises.push(createFabricObjectFromBlob(dummyBlob, w, h, totalImagePixels).then(obj => {
                  obj._isOcrCandidate = true;
                  return obj;
              }));

          } else if (CURRENT_PARAMS.splitObjects) {
              
const blobs = findConnectedComponents(w, h, data);
console.log("[DEBUG] blobs length:", blobs.length);
blobs.forEach(b => {
    if (b.minX > 2100 && b.minX < 2700 && b.minY > 1250 && b.minY < 1350) {
        console.log("[DEBUG-RAW-BLOB-AI-GOV]", { minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY, pixelCount: b.pixels.length });
    }
});

              blobs.forEach(blob => {
                  promises.push(createFabricObjectFromBlob(blob, w, h, totalImagePixels));
              });
          } else {
              ctx.putImageData(imageData, 0, 0);
              const dummyBlob = { minX:0, minY:0, maxX:w, maxY:h, isFullImage:true, imageData:imageData };
              promises.push(createFabricObjectFromBlob(dummyBlob, w, h, totalImagePixels));
          }
      
          if (CURRENT_PARAMS.keepBackground && bgImageData) {
              const bgBlob = { minX: 0, minY: 0, maxX: w-1, maxY: h-1, isFullImage: true, imageData: bgImageData };
              promises.push(createFabricObjectFromBlob(bgBlob, w, h, totalImagePixels).then(obj => {
                  obj.isBackground = true;
                  obj.exclusionReason = "背景レイヤー";
                  obj.isBlackish = false;
                  obj.isColorChar = false;
                  obj.selectable = true;
                  obj.evented = true;
                  return obj;
              }));
          }
      
          const placedObjectsRaw = await Promise.all(promises);

          const placedObjects = placedObjectsRaw.filter(obj => {
              if (!obj) return false;
              if (obj.isBackground) return true;
              // forceOcr時は密度フィルタもスキップ（ユーザー指定領域は強制OCR対象）
              if (CURRENT_PARAMS.forceOcr) return true;
              const area = obj.originalRect.w * obj.originalRect.h;
              const density = area > 0 ? (obj.pixelCount / area) * 100 : 0;
              return density > 5.0;
          });

          if (CURRENT_PARAMS.forceOcr) {
              // forceOcr時はgroupAndAddObjects（分類・再分割）をスキップして直接追加
              // これにより1つの統合されたテキスト候補として扱われ、Tesseractに全文が渡る
              placedObjects.forEach(obj => {
                  if (!obj.isBackground) {
                      obj._isOcrCandidate = true;
                  }
                  canvas.add(obj);
              });
          } else if (CURRENT_PARAMS.showAnalysis) {
              groupAndAddObjects(placedObjects);
          } else {
              placedObjects.forEach(obj => canvas.add(obj));
          }
          
          // デフォルトのビューモードに戻す
          switchMainView(currentViewMode);
      
          // ★変更: 判定結果に基づいてOCRを実行するかスキップするか制御
          canvas.renderAll();
      
          // ここで再度輝度チェック（念のため）
          const domCCheck = ocrOriginalImage.dominantColor || { r: 255, g: 255, b: 255 };
          const bgBrightnessCheck = (domCCheck.r + domCCheck.g + domCCheck.b) / 3;
      
          if (CURRENT_PARAMS.forceOcr || (shouldRunOcr && bgBrightnessCheck >= 100)) {
              // ★追加: 充填率フィルタリングを実行（forceOcr時はスキップして全候補をOCR）
              if (!CURRENT_PARAMS.forceOcr) {
                  await checkAndFilterByFillRate(all);
              }

              // 再描画（枠が消えたものを反映）
              canvas.renderAll();

              await startBatchOCR(all);
          } else {
              let reason = "";
              if (!shouldRunOcr) reason = ocrDecisionDisplay.title.replace('スキップ理由: ', '');
              if (bgBrightnessCheck < 100) reason += (reason ? ", " : "") + "背景過暗";
      
              // ★変更: スキップ通知
              showNotification("OCRスキップ", reason, "info");
              
              // スキップしたことをUIにも反映
              ocrResultText.placeholder = `OCRは実行されていません。\n理由: ${reason}`;
              
              // 表示も更新
              if (bgBrightnessCheck < 100) {
                   ocrDecisionDisplay.textContent = `OCR: スキップ`;
                   ocrDecisionDisplay.title = `スキップ理由: ${reason}`;
                   ocrDecisionDisplay.className = "text-[10px] sm:text-xs font-bold px-2 py-1 rounded bg-orange-100 text-orange-700 border border-orange-200 whitespace-nowrap";
              }
          }
      }
      
      // ★追加: 欠落していた関数 detectThreePointGradient
      function detectThreePointGradient(ctx, w, h) {
          const imgData = ctx.getImageData(0, 0, w, h);
          const data = imgData.data;
      
          const getBrightestColorInColumn = (targetX) => {
              let maxBrightness = -1;
              let brightest = { r: 255, g: 255, b: 255 }; 
              if (targetX < 0) targetX = 0;
              if (targetX >= w) targetX = w - 1;
      
              for (let y = 0; y < h; y++) {
                  const idx = (y * w + targetX) * 4;
                  const r = data[idx];
                  const g = data[idx+1];
                  const b = data[idx+2];
                  const brightness = r + g + b;
                  
                  if (brightness > maxBrightness) {
                      maxBrightness = brightness;
                      brightest = { r, g, b };
                  }
              }
              return brightest;
          };
      
          const left = getBrightestColorInColumn(0);
          const center = getBrightestColorInColumn(Math.floor(w / 2));
          const right = getBrightestColorInColumn(w - 1);
      
          const getDiff = (c1, c2) => Math.sqrt((c1.r - c2.r)**2 + (c1.g - c2.g)**2 + (c1.b - c2.b)**2);
      
          const diffLC = getDiff(left, center);
          const diffCR = getDiff(center, right);
          const diffLR = getDiff(left, right);
      
          const maxDiff = Math.max(diffLC, diffCR, diffLR);
          // ★変更: グラデーション判定の閾値を 45 から 37 に厳格化
          const threshold = 37; 
      
          const hasGradient = maxDiff > threshold;
          const reason = `最大色差: ${maxDiff.toFixed(1)} (閾値: ${threshold})`;
      
          return { hasGradient, reason, maxDiff };
      }
      
      // ★追加: 欠落していた関数 prepareImageForOCR
      function prepareImageForOCR(sourceCanvas, paddingText = "ABC") {
          const scale = 2.0;
          const scaledWidth = sourceCanvas.width * scale;
          const scaledHeight = sourceCanvas.height * scale;
          const scaledCanvas = document.createElement('canvas');
          scaledCanvas.width = scaledWidth;
          scaledCanvas.height = scaledHeight;
          const sCtx = scaledCanvas.getContext('2d');
          sCtx.imageSmoothingEnabled = true;
          sCtx.imageSmoothingQuality = 'high';
          sCtx.drawImage(sourceCanvas, 0, 0, scaledWidth, scaledHeight);
          
          const outerPadding = 50; 
          const innerPadding = 10; 
          
          const fontSize = Math.max(20, scaledHeight * 0.90); 
          const dummyText = paddingText;
          
          const tempCtx = document.createElement('canvas').getContext('2d');
          tempCtx.font = `bold ${fontSize}px sans-serif`;
          const textWidth = tempCtx.measureText(dummyText).width;
          
          const newWidth = outerPadding + textWidth + innerPadding + scaledWidth + innerPadding + textWidth + outerPadding;
          const contentHeight = Math.max(scaledHeight, fontSize);
          const newHeight = contentHeight + (outerPadding * 2);
          
          const newCanvas = document.createElement('canvas');
          newCanvas.width = newWidth;
          newCanvas.height = newHeight;
          const ctx = newCanvas.getContext('2d');
          
          ctx.fillStyle = "#ffffff";
          ctx.fillRect(0, 0, newWidth, newHeight);
          
          ctx.font = `bold ${fontSize}px sans-serif`;
          ctx.fillStyle = "#000000";
          ctx.textBaseline = "middle";
          
          const centerY = newHeight / 2;
          ctx.fillText(dummyText, outerPadding, centerY);
          
          const targetX = outerPadding + textWidth + innerPadding;
          const targetY = (newHeight - scaledHeight) / 2;
          ctx.drawImage(scaledCanvas, targetX, targetY);
          
          ctx.fillText(dummyText, targetX + scaledWidth + innerPadding, centerY);
          return newCanvas;
      }
      
      // ★追加: 欠落していた関数 executeOCRWithRetry
      async function executeOCRWithRetry(canvas, worker) {
          const finalDataUrl = canvas.toDataURL('image/jpeg', 1.0);
          let result = await worker.recognize(finalDataUrl);
          let text = result.data.text.trim();
          return text;
      }
      
      // ★追加: アプリ起動時にバックグラウンドでOCRエンジンを事前起動
      window.addEventListener('DOMContentLoaded', () => {
          console.log("Pre-loading OCR engine in background...");
          initTesseractScheduler().catch(e => console.error("OCR engine pre-load failed:", e));
      });
      
      // ==========================================
      // ▼▼▼ 追加機能エリア (JSON出力 & 比較読込 & 表示切替) ▼▼▼
      // ==========================================
      
      const downloadJsonBtn = document.getElementById('downloadJsonBtn');
      const importJsonBtn = document.getElementById('importJsonBtn');
      const importJsonInput = document.getElementById('importJsonInput');
      const compareToggleWrap = document.getElementById('compareToggleWrap'); // ★追加
      const compareToggle = document.getElementById('compareToggle');       // ★追加
      
      // --- 通知関数の拡張 (ボタン表示制御) ---
      const originalShowNotification = showNotification;
      showNotification = function(title, desc, type) {
          originalShowNotification(title, desc, type);
          
          // 解析完了時にボタンを表示
          if (type === 'success') {
              downloadJsonBtn.classList.remove('hidden');
              importJsonBtn.classList.remove('hidden');
          }
      };
      
      // --- 1. JSON出力機能 (修正版: ズレ補正) ---
      downloadJsonBtn.addEventListener('click', () => {
          const exportData = [];
          const objects = canvas.getObjects().filter(o => !o.isComparisonGhost);
      
          objects.forEach(obj => {
              if (!obj._isOcrCandidate || !obj.detectedText) return;
      
              // ★修正: 画面上の見た目座標ではなく、元画像の正確な座標(originalRect)を使用する
              let finalBox = null;
              let colorInfo = obj.avgColor || { r: 0, g: 0, b: 0 };
      
              if (obj.type === 'group') {
                  // グループの場合、中身の画像オブジェクトが持つ originalRect を統合して計算
                  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                  let hasRect = false;
                  
                  const children = obj.getObjects();
                  children.forEach(child => {
                      // 枠線(visualFrame)などは無視して、画像(originalRect持ち)だけを見る
                      if (child.originalRect) {
                          hasRect = true;
                          minX = Math.min(minX, child.originalRect.x);
                          minY = Math.min(minY, child.originalRect.y);
                          maxX = Math.max(maxX, child.originalRect.x + child.originalRect.w);
                          maxY = Math.max(maxY, child.originalRect.y + child.originalRect.h);
                          
                          // 色情報も取得（グループの場合、代表して最初に見つかった子の色を使う）
                          if (!obj.avgColor && child.avgColor) {
                              colorInfo = child.avgColor;
                          }
                      }
                  });
      
                  if (hasRect) {
                      finalBox = {
                          x: minX,
                          y: minY,
                          width: maxX - minX,
                          height: maxY - minY
                      };
                  }
              } else if (obj.originalRect) {
                  // 単体オブジェクトの場合、その originalRect をそのまま使用
                  finalBox = {
                      x: obj.originalRect.x,
                      y: obj.originalRect.y,
                      width: obj.originalRect.w,
                      height: obj.originalRect.h
                  };
              }
      
              // 万が一 originalRect が取れなかった場合のフォールバック（画面座標から逆算）
              if (!finalBox) {
                  const scaleX = obj.scaleX || 1;
                  const scaleY = obj.scaleY || 1;
                  const width = obj.width * scaleX;
                  const height = obj.height * scaleY;
                  finalBox = {
                      x: Math.round(obj.left - (width / 2)),
                      y: Math.round(obj.top - (height / 2)),
                      width: Math.round(width),
                      height: Math.round(height)
                  };
              }
      
              const data = {
                  id: Math.random().toString(36).substr(2, 9),
                  text: obj.detectedText,
                  raw_text: obj.rawOcrText || "",
                  box: { 
                      x: Math.round(finalBox.x), 
                      y: Math.round(finalBox.y), 
                      width: Math.round(finalBox.width), 
                      height: Math.round(finalBox.height) 
                  },
                  color: { r: Math.round(colorInfo.r), g: Math.round(colorInfo.g), b: Math.round(colorInfo.b) },
                  attributes: {
                      is_vertical: (finalBox.height > finalBox.width * 1.5),
                      is_blackish: !!obj.isBlackish,
                      is_color: !!obj.isColorChar
                  }
              };
              exportData.push(data);
          });
      
          if (exportData.length === 0) {
              alert("出力できるOCRデータがありません。\n画像を解析してから実行してください。");
              return;
          }
      
          exportData.sort((a, b) => {
              const yDiff = a.box.y - b.box.y;
              if (Math.abs(yDiff) > 10) return yDiff;
              return a.box.x - b.box.x;
          });
      
          const jsonString = JSON.stringify(exportData, null, 2);
          const blob = new Blob([jsonString], { type: 'application/json' });
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = `ocr_result_${new Date().getTime()}.json`;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          URL.revokeObjectURL(url);
      });      
      // --- 2. 比較読込機能 (イベント設定) ---
      importJsonBtn.addEventListener('click', () => {
          importJsonInput.value = '';
          importJsonInput.click();
      });
      
      importJsonInput.addEventListener('change', (e) => {
          const file = e.target.files[0];
          if (!file) return;
      
          const reader = new FileReader();
          reader.onload = (event) => {
              try {
                  const json = JSON.parse(event.target.result);
                  executeComparison(json); // 比較実行
              } catch (err) {
                  alert("JSONファイルの読み込みに失敗しました。\n形式を確認してください。");
                  console.error(err);
              }
          };
          reader.readAsText(file);
      });
      
      // --- 3. 表示切替機能 (イベント設定) ---
      compareToggle.addEventListener('change', (e) => {
          toggleComparisonMode(e.target.checked);
      });
      
      // --- 比較ロジック本体 ---
      function executeComparison(jsonData) {
          // 既存のゴーストを削除 & 既存オブジェクトの状態リセット
          const ghosts = canvas.getObjects().filter(o => o.isComparisonGhost);
          ghosts.forEach(g => canvas.remove(g));
          
          const currentObjects = canvas.getObjects().filter(o => o._isOcrCandidate && !o.isComparisonGhost);
          
          // 前回の比較情報をリセット
          currentObjects.forEach(obj => {
              delete obj.comparisonStatus;
              delete obj.comparisonDiffText;
              // 元のテキストに戻しておく
              if (obj._originalDetectedText) {
                  obj.detectedText = obj._originalDetectedText;
                  delete obj._originalDetectedText;
              }
          });
      
          let matchCount = 0;
          let diffCount = 0;
          let missingCount = 0;
      
          jsonData.forEach(oldItem => {
              const oldBox = oldItem.box;
              const oldCenterX = oldBox.x + oldBox.width / 2;
              const oldCenterY = oldBox.y + oldBox.height / 2;
      
              const matchedObj = currentObjects.find(obj => {
                  const scaleX = obj.scaleX || 1;
                  const scaleY = obj.scaleY || 1;
                  const currentW = obj.width * scaleX;
                  const currentH = obj.height * scaleY;
                  
                  const dist = Math.sqrt(Math.pow(obj.left - oldCenterX, 2) + Math.pow(obj.top - oldCenterY, 2));
                  const avgSize = (currentW + currentH) / 2;
                  
                  const isPosClose = dist < (avgSize * 0.4); 
                  const isSizeClose = (Math.abs(currentW - oldBox.width) / currentW < 0.3) &&
                                      (Math.abs(currentH - oldBox.height) / currentH < 0.3);
      
                  return isPosClose && isSizeClose;
              });
      
              if (matchedObj) {
                  const cleanOld = (oldItem.text || "").replace(/\s+/g, '').trim();
                  const cleanNew = (matchedObj.detectedText || "").replace(/\s+/g, '').trim();
      
                  if (cleanOld === cleanNew) {
                      matchedObj.comparisonStatus = 'match'; // 一致
                      matchCount++;
                  } else {
                      matchedObj.comparisonStatus = 'diff';  // 変更あり
                      matchedObj.comparisonDiffText = `【変更あり】\n旧: ${oldItem.text}\n新: ${matchedObj.detectedText}`;
                      diffCount++;
                  }
                  matchedObj._isCompared = true; // 比較済みフラグ
              } else {
                  // 削除されたアイテム（ゴースト作成）
                  // ★変更: 色を赤からマゼンタに変更して区別
                  const ghostRect = new fabric.Rect({
                      left: oldCenterX,
                      top: oldCenterY,
                      width: oldBox.width,
                      height: oldBox.height,
                      originX: 'center',
                      originY: 'center',
                      fill: 'rgba(217, 70, 239, 0.1)', // 薄いマゼンタ背景
                      stroke: '#d946ef',              // マゼンタ枠線
                      strokeWidth: 2,
                      strokeDashArray: [4, 4],        // 点線
                      selectable: true,
                      evented: true,
                      visible: true 
                  });
                  ghostRect.isComparisonGhost = true;
                  ghostRect._isOcrCandidate = true;
                  ghostRect.detectedText = `【削除済み】\n旧: ${oldItem.text}`;
                  ghostRect.exclusionReason = `以前のJSONには存在しましたが、\n現在の解析では検出されませんでした。`;
                  
                  canvas.add(ghostRect);
                  missingCount++;
              }
          });
      
          // 新規アイテムの判定
          let newCount = 0;
          currentObjects.forEach(obj => {
              if (!obj._isCompared) {
                  obj.comparisonStatus = 'new'; // 新規
                  newCount++;
              }
              delete obj._isCompared; // 一時フラグ削除
          });
      
          // ★トグルUIを表示して「ON」にする
          compareToggleWrap.classList.remove('hidden');
          compareToggle.checked = true;
          
          // ★表示モードを適用
          toggleComparisonMode(true);
      
          alert(`比較完了:\n・一致: ${matchCount}件 (グレー)\n・変更: ${diffCount}件 (オレンジ)\n・削除: ${missingCount}件 (マゼンタ点線)\n・新規: ${newCount}件 (パープル)`);
      }      
      // --- 表示モード切り替え関数 (修正版) ---
      function toggleComparisonMode(isShow) {
          const objects = canvas.getObjects();
          
          // 動的な太さの再計算
          let strokeW = 2;
          if (ocrOriginalImage) {
              const maxDim = Math.max(ocrOriginalImage.width, ocrOriginalImage.height);
              strokeW = Math.max(2, Math.floor(maxDim * 0.003));
          }
      
          // ★ヘルパー関数: オブジェクトまたはグループ内の枠線の色を変更する
          const setTargetStroke = (targetObj, color, width) => {
              if (targetObj.type === 'group') {
                  // グループの場合、中の 'isVisualFrame' を持つ部品を探して色を変える
                  const frame = targetObj.getObjects().find(o => o.isVisualFrame);
                  if (frame) {
                      frame.set({ stroke: color, strokeWidth: width });
                  }
                  targetObj.dirty = true; // 再描画フラグを立てる
              } else {
                  // 単体オブジェクト（ゴーストなど）の場合は直接変える
                  targetObj.set({ stroke: color, strokeWidth: width });
              }
          };
      
          objects.forEach(obj => {
              // 1. ゴースト（削除済みアイテム）の制御
              if (obj.isComparisonGhost) {
                  obj.visible = isShow;
                  return;
              }
      
              // OCR候補でないものはスキップ（背景など）
              if (!obj._isOcrCandidate) return;
      
              // 2. 通常オブジェクトのスタイル切り替え
              if (isShow && obj.comparisonStatus) {
                  // --- 比較モードON: 独自カラーパレットを適用 ---
                  
                  if (obj.comparisonStatus === 'match') {
                      // 一致 -> 薄いグレー
                      setTargetStroke(obj, '#cbd5e1', 1);
                  
                  } else if (obj.comparisonStatus === 'diff') {
                      // 変更 -> オレンジ
                      setTargetStroke(obj, '#f97316', strokeW * 2);
                      
                      // テキストを新旧表示に切り替え
                      if(obj.comparisonDiffText) {
                          if (!obj._originalDetectedText) obj._originalDetectedText = obj.detectedText;
                          obj.detectedText = obj.comparisonDiffText;
                      }
                  
                  } else if (obj.comparisonStatus === 'new') {
                      // 新規 -> パープル
                      setTargetStroke(obj, '#a855f7', strokeW * 2);
                  }
      
              } else {
                  // --- 比較モードOFF: 元の解析結果カラーに戻す ---
                  let originalColor = 'blue'; // デフォルト(カラー文字)
                  if (obj.isRescuedDarkGroup) originalColor = '#059669'; // 救済(エメラルド)
                  else if (obj.isBlackish) originalColor = 'red';        // 黒文字(赤)
                  
                  // スタイル復元
                  const originalWidth = obj.isRescuedDarkGroup ? strokeW * 1.5 : strokeW;
                  setTargetStroke(obj, originalColor, originalWidth);
      
                  // テキスト復元（バックアップがあれば）
                  if (obj._originalDetectedText) {
                      obj.detectedText = obj._originalDetectedText;
                      delete obj._originalDetectedText;
                  } else if (obj.detectedText && obj.detectedText.startsWith("【変更あり】")) {
                      const match = obj.detectedText.match(/新:\s*(.*)$/s);
                      if (match) obj.detectedText = match[1];
                  }
              }
          });
          
          canvas.renderAll();
      }    
    
                /* --- LOGIC SCRIPT 4 END --- */

                /* --- LOGIC SCRIPT 5 START --- */
                const e = { data: { type: 'PROCESS_IMAGE', params: EXTERN_PARAMS }, ports: [{ postMessage: () => {} }] };
                // Headless Wrapper

    if (e.data.type === 'PROCESS_IMAGE') {
        const port = e.ports[0];
        try {
            // Override params
            Object.assign(CURRENT_PARAMS, e.data.params);
            
            // [Fix] Remove window.showNotification override here because it destroys the global UI notification function in processImageToJSON.
            // (It was intended for Web Worker messaging which is not used here)
            
            window._generateJSON = function() { const exportData = [];
console.log("[DEBUG] generateJSON start! Total objects:", canvas.getObjects().length);
                const objects = canvas.getObjects().filter(o => !o.isComparisonGhost);
                objects.forEach(obj => {
                    if (!CURRENT_PARAMS.debugMode && (!obj._isOcrCandidate || !obj.detectedText)) { 
                        console.log("[DEBUG] skipping:", obj.detectedText, obj._isOcrCandidate); 
                        return; 
                    }
                    let finalBox = null;
                    console.log("[DEBUG] generateJSON obj.avgColor:", obj.avgColor);
                    let colorInfo = obj.avgColor || { r: 0, g: 0, b: 0 };
                    
                    // グループオブジェクト自体に originalRect が設定されている場合、それを最優先で使用！
                    if (obj.originalRect) {
                        finalBox = { x: obj.originalRect.x, y: obj.originalRect.y, width: obj.originalRect.w, height: obj.originalRect.h };
                    } else if (obj.type === 'group') {
                        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                        let hasRect = false;
                        const children = obj.getObjects();
                        children.forEach(child => {
                            console.log("[DEBUG] child type:", child.type, "avgColor:", child.avgColor, "hasRect:", !!child.originalRect);
                            if (child.originalRect) {
                                hasRect = true;
                                minX = Math.min(minX, child.originalRect.x);
                                minY = Math.min(minY, child.originalRect.y);
                                maxX = Math.max(maxX, child.originalRect.x + child.originalRect.w);
                                maxY = Math.max(maxY, child.originalRect.y + child.originalRect.h);
                                if (!obj.avgColor && child.avgColor) colorInfo = child.avgColor;
                            }
                        });
                        if (hasRect) {
                            finalBox = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
                        }
                    }
                    
                    if (!finalBox) {
                        const scaleX = obj.scaleX || 1;
                        const scaleY = obj.scaleY || 1;
                        
                        // キャンバス座標系から元の画像座標系に逆変換するためのスケールファクター
                        let scaleFactor = 1.0;
                        if (typeof ocrOriginalImage !== 'undefined' && ocrOriginalImage) {
                            const imgW = ocrOriginalImage.naturalWidth || ocrOriginalImage.width || 1;
                            scaleFactor = canvas.width / imgW;
                        } else if (typeof origW !== 'undefined') {
                            scaleFactor = canvas.width / origW;
                        }
                        
                        const width = (obj.width * scaleX) / scaleFactor;
                        const height = (obj.height * scaleY) / scaleFactor;
                        
                        // left/top は中心または左上基準。Fabric.js で left は中心 originX が center でない限り左上。
                        // canvas 上でのオブジェクトの座標を、1/scaleFactor で完全に等倍に逆変換
                        const leftInCanvas = obj.left;
                        const topInCanvas = obj.top;
                        
                        // originX/originY を考慮してバウンディングボックスの左上座標を等倍に逆変換する
                        let l = leftInCanvas;
                        let t = topInCanvas;
                        if (obj.originX === 'center') {
                            l = leftInCanvas - (obj.width * scaleX) / 2;
                        }
                        if (obj.originY === 'center') {
                            t = topInCanvas - (obj.height * scaleY) / 2;
                        }
                        
                        finalBox = {
                            x: Math.round(l / scaleFactor),
                            y: Math.round(t / scaleFactor),
                            width: Math.round(width),
                            height: Math.round(height)
                        };
                    }
                    const elementData = {
                        id: Math.random().toString(36).substr(2, 9),
                        text: (function(){ 
                            let exported = obj.detectedText || "";
                            exported = exported.replace(/(\d),\s+(\d)/g, '$1,$2');
                            console.log("[DEBUG JSON] exporting: ", exported); 
                            return exported; 
                        })(),
                        raw_text: obj.rawOcrText || "",
                        box: { x: Math.round(finalBox.x), y: Math.round(finalBox.y), width: Math.round(finalBox.width), height: Math.round(finalBox.height) },
                        color: { r: Math.round(colorInfo.r), g: Math.round(colorInfo.g), b: Math.round(colorInfo.b) },
                        attributes: { is_vertical: (finalBox.height > finalBox.width * 1.5), is_blackish: !!obj.isBlackish, is_color: !!obj.isColorChar }
                    };

                    if (CURRENT_PARAMS.debugMode) {
                        // グループの場合、内部メンバー（個別オブジェクト/星）の位置情報を収集
                        let memberInfo = [];
                        if (obj.type === 'group' && typeof obj.getObjects === 'function') {
                            obj.getObjects().forEach(child => {
                                if (child.originalRect) {
                                    memberInfo.push({
                                        x: child.originalRect.x,
                                        y: child.originalRect.y,
                                        width: child.originalRect.w,
                                        height: child.originalRect.h,
                                        isStar: false
                                    });
                                } else if (child.isStar) {
                                    memberInfo.push({
                                        x: child.left,
                                        y: child.top,
                                        width: 0,
                                        height: 0,
                                        isStar: true,
                                        starReason: child.starReason || null
                                    });
                                }
                            });
                        }

                        elementData.debug_info = {
                            is_ocr_candidate: !!obj._isOcrCandidate,
                            has_detected_text: !!obj.detectedText,
                            inclusionReason: obj.inclusionReason || null,
                            exclusionReason: obj.exclusionReason || null,
                            calculatedFillRate: obj.calculatedFillRate || null,
                            stats: obj.stats || null,
                            flags: {
                                isBlackish: !!obj.isBlackish,
                                isColorChar: !!obj.isColorChar,
                                isRescuedDarkGroup: !!obj.isRescuedDarkGroup,
                                isRescuedDark: !!obj.isRescuedDark,
                                isInverted: !!obj.isInverted,
                                isStar: !!obj.isStar,
                                starReason: obj.starReason || null
                            },
                            ocrDebugImageSrc: obj.ocrDebugImageSrc || null,
                            pixelCount: obj.pixelCount || null,
                            originalMemberCount: obj._originalMemberCount || null,
                            members: memberInfo
                        };
                    }
                    exportData.push(elementData);
                });
                exportData.sort((a, b) => {
                    const yDiff = a.box.y - b.box.y;
                    if (Math.abs(yDiff) > 10) return yDiff;
                    return a.box.x - b.box.x;
                });

                // ★追加: デバッグモード時に解析画像とOCR用画像を結果配列に添付
                if (CURRENT_PARAMS.debugMode) {
                    exportData.analysisImageSrc = window._debugAnalysisImageSrc || null;
                    try {
                        if (typeof fullOcrImageSrc !== 'undefined' && fullOcrImageSrc) {
                            exportData.ocrPreviewImageSrc = fullOcrImageSrc;
                        }
                    } catch(_) {}
                }
                return exportData;
            }

            // Start process
            await handleFile(e.data.file);

        } catch (err) {
            port.postMessage({ type: 'ERROR', message: err.toString() });
        }
    }

                /* --- LOGIC SCRIPT 5 END --- */

                CURRENT_PARAMS = Object.assign(CURRENT_PARAMS || {}, EXTERN_PARAMS);
                
                // 元の関数を保存
                const __originalShowNotification = showNotification;

                // Override the hoisted showNotification function!
                showNotification = function(title, desc, type) {
                    console.log("[PureEngine]", title, desc, "type:", type);
                    if (type === 'error') {
                        showNotification = __originalShowNotification; // 復元
                        reject(new Error(desc));
                    }
                    else if (type === 'info' && (title.indexOf('スキップ') !== -1 || title.indexOf('skip') !== -1)) {
                        // OCR skipped (e.g. dark background) - resolve with empty array
                        console.log("[PureEngine] OCR skipped, resolving with empty array");
                        showNotification = __originalShowNotification; // 復元
                        resolve([]);
                    }
                    else if (type === 'success' && (title.indexOf('完') !== -1 || title.indexOf('SUCCESS') !== -1 || title.indexOf('success') !== -1)) {
                        try {
                            console.log("[DEBUG] showNotification caught success. window._generateJSON:", typeof window._generateJSON);
                            if (typeof window._generateJSON === 'function') {
                                const result = window._generateJSON();
                                showNotification = __originalShowNotification; // 復元
                                resolve(typeof result === 'string' ? JSON.parse(result) : result);
                            } else {
                                showNotification = __originalShowNotification; // 復元
                                resolve([]);
                            }
                        } catch(err) {
                            showNotification = __originalShowNotification; // 復元
                            reject(err);
                        }
                    }
                };

                const reader = new FileReader();
                reader.onload = (e) => {
                    const img = new window.Image();
                    img.onload = () => { 
                        ocrOriginalImage = img; 
                        
                        // ヘッドレス側のダミーキャンバスの論理サイズも、画像原寸（等倍）に100%同期アジャストする！
                        if (typeof canvas !== 'undefined' && canvas && typeof canvas.setDimensions === 'function') {
                            canvas.setDimensions({ width: img.width, height: img.height });
                        }
                        
                        try {
                            ocrOriginalImage.dominantColor = calculateDominantColor(img);
                            const analysis = analyzeImageStyle(img);
                            console.log("[PureEngine] analyzeImageStyle result:", analysis);
                            if (analysis.isPhotoLike) {
                                CURRENT_PARAMS.forceBlackText = true;
                            } else {
                                CURRENT_PARAMS.forceBlackText = false;
                            }
                        } catch(e) {
                            console.log("[PureEngine] analyzeImageStyle failed:", e);
                        }
                        processImage(img); 
                    };
                    img.src = e.target.result;
                };
                reader.readAsDataURL(imageFile);

            })(document, fabric, PARAMS);
        });
    }

    /**
     * 領域画像からテキストを抽出します（単独処理用ラッパー）
     * processImageToJSON と同じコアロジックを使用するため、メインロジック変更時に自動追従します。
     *
     * ※ ユーザーが指定したエリアは「確実にテキスト」であるため、
     *   背景率不足・背景過暗などの自動スキップ判定をバイパスして強制的にOCRを実行します。
     *
     * @param {HTMLCanvasElement} canvasElement - 切り出し済みの領域画像Canvas
     * @param {Function} onProgress - 進捗コールバック (percent, title, detail) => void
     * @returns {Promise<Array>} OCR結果のJSON配列 [{ text, box: {x, y, width, height} }, ...]
     */
    async processRegionToJSON(canvasElement, onProgress) {
        const blob = await new Promise(res => canvasElement.toBlob(res, 'image/png'));
        const file = new File([blob], 'region.png', { type: 'image/png' });
        const prevForceOcr = this.params.forceOcr;
        this.params.forceOcr = true;
        try {
            return await this.processImageToJSON(file, onProgress);
        } finally {
            this.params.forceOcr = prevForceOcr;
        }
    }
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
    module.exports = { PureTextProcessingEngine };
} else {
    window.PureTextProcessingEngine = PureTextProcessingEngine;
}

/* ===== END PureTextProcessingEngine.js ===== */
  // [majin-build] 自己 export(window.X=) を持たないエンジン(class 宣言のみ。例: PptxExportEngine)でも
  //   クラス束縛を overlay 経由で window.NLM.PureTextProcessingEngine に確実に載せる(自己 export 済みなら冪等な二重代入)。
  try { if (typeof PureTextProcessingEngine !== 'undefined') { window.PureTextProcessingEngine = PureTextProcessingEngine; } } catch (e) {}
  })(__win);
  __realWindow.NLM.PureTextProcessingEngine = __overlay.PureTextProcessingEngine; // エンジンの window.PureTextProcessingEngine= は overlay に入る
})(window);
  </script>
  <script id="nlm-image-engine">
;(function (__realWindow) {
  if (!__realWindow.NLM) __realWindow.NLM = {};
  // === サンドボックス: window を Proxy 化(原本エンジンは無改変で読み書きできる) ===
  var __UI_EVENTS = { paste: 1, resize: 1, DOMContentLoaded: 1 };
  var __overlay = Object.create(null); // エンジンが書く window.X はここに隔離(実window非汚染)
  var __win = new Proxy(__realWindow, {
    get: function (t, p) {
      if (p in __overlay) return __overlay[p];
      if (p === 'addEventListener') {
        return function (type, fn, opts) { if (__UI_EVENTS[type]) return; return t.addEventListener(type, fn, opts); };
      }
      var v = Reflect.get(t, p); // 受け手=実window: getter(document/navigator等)の this が実windowになり Illegal invocation を回避
      return (typeof v === 'function') ? v.bind(t) : v;
    },
    set: function (t, p, val) { __overlay[p] = val; return true; },
    has: function (t, p) { return (p in __overlay) || (p in t); }
  });
  (function (window) {
/* ===== BEGIN ImageProcessingEngine.js (verbatim copy from nlm_modules/ImageProcessingEngine.js + build時 majin-patch) ===== */
/**
 * ImageProcessingEngine.js
 * 
 * 完全に独立した画像分解・インペイントエンジン。
 * 画像とテキスト抽出JSONを入力とし、レイヤー分解された要素と背景画像を出力します。
 * UIに一切依存せず、どのプロジェクトでも利用可能なコンポーザブルな設計です。
 */

class ImageProcessingEngine {
    constructor() {
        // AutoImageSplitter本体をこのインスタンス内に初期化
        this._engine = (function() {
            const DEFAULT_PARAMS = {
          threshold: 30,
          minSegmentPixels: 100, 
          transparencyThreshold: 15 
        };
      
        function getPixelIntensity(data, i) {
          return data[i + 3] === 0 ? 0 : 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
        }
      
        function detectEdges(data, width, height, threshold) {
          const edgeMap = new Uint8Array(width * height);
          const kernelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
          const kernelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
      
          for (let y = 1; y < height - 1; y++) {
            for (let x = 1; x < width - 1; x++) {
              let pixelX = 0;
              let pixelY = 0;
              for (let ky = -1; ky <= 1; ky++) {
                const rowOffset = (y + ky) * width;
                for (let kx = -1; kx <= 1; kx++) {
                  const i = (rowOffset + (x + kx)) * 4;
                  const intensity = getPixelIntensity(data, i);
                  pixelX += intensity * kernelX[ky + 1][kx + 1];
                  pixelY += intensity * kernelY[ky + 1][kx + 1];
                }
              }
              const magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY);
              if (magnitude > threshold) {
                edgeMap[y * width + x] = 255;
              }
            }
          }
          return edgeMap;
        }
      
        function calculateInpaintedColors(pixels, width, height, segmentPixels) {
          const segmentSet = new Set(segmentPixels);
          const colorMap = new Map();
      
          const addColor = (idx, r, g, b) => {
              if (!colorMap.has(idx)) {
                  colorMap.set(idx, { r: 0, g: 0, b: 0, count: 0 });
              }
              const entry = colorMap.get(idx);
              entry.r += r;
              entry.g += g;
              entry.b += b;
              entry.count++;
          };
      
          let minX = width, maxX = 0, minY = height, maxY = 0;
          for (let idx of segmentPixels) {
              const x = idx % width;
              const y = Math.floor(idx / width);
              if (x < minX) minX = x;
              if (x > maxX) maxX = x;
              if (y < minY) minY = y;
              if (y > maxY) maxY = y;
          }

          // 境界周囲のピクセルを収集（バウンディングボックスの周囲最大3px）
          const borderPixels = [];
          for (let y = Math.max(0, minY - 3); y <= Math.min(height - 1, maxY + 3); y++) {
              for (let x = Math.max(0, minX - 3); x <= Math.min(width - 1, maxX + 3); x++) {
                  const idx = y * width + x;
                  // 対象領域そのものでなく、バウンディングボックスの外側のピクセルのみを採用
                  if (!segmentSet.has(idx) && (x < minX || x > maxX || y < minY || y > maxY)) {
                      borderPixels.push({
                          x: x,
                          y: y,
                          r: pixels[idx*4],
                          g: pixels[idx*4+1],
                          b: pixels[idx*4+2]
                      });
                  }
              }
          }

          const N = borderPixels.length;
          if (N === 0) {
              for (let idx of segmentPixels) addColor(idx, 255, 255, 255);
              return colorMap;
          }

          // 最小二乗法による2Dグラデーション平面（R(x,y) = ax + by + c）の近似計算
          let sumX=0, sumY=0, sumR=0, sumG=0, sumB=0;
          for (let p of borderPixels) {
              sumX += p.x; sumY += p.y;
              sumR += p.r; sumG += p.g; sumB += p.b;
          }
          const meanX = sumX / N;
          const meanY = sumY / N;
          const meanR = sumR / N;
          const meanG = sumG / N;
          const meanB = sumB / N;

          let Sxx=0, Syy=0, Sxy=0;
          let SxR=0, SxG=0, SxB=0;
          let SyR=0, SyG=0, SyB=0;

          for (let p of borderPixels) {
              const dx = p.x - meanX;
              const dy = p.y - meanY;
              Sxx += dx * dx;
              Syy += dy * dy;
              Sxy += dx * dy;
              SxR += dx * (p.r - meanR); SxG += dx * (p.g - meanG); SxB += dx * (p.b - meanB);
              SyR += dy * (p.r - meanR); SyG += dy * (p.g - meanG); SyB += dy * (p.b - meanB);
          }

          let aR=0, bR=0, aG=0, bG=0, aB=0, bB=0;
          const D = Sxx * Syy - Sxy * Sxy;
          
          if (Math.abs(D) > 1e-5) {
              aR = (SxR * Syy - SyR * Sxy) / D;
              bR = (SyR * Sxx - SxR * Sxy) / D;
              aG = (SxG * Syy - SyG * Sxy) / D;
              bG = (SyG * Sxx - SxG * Sxy) / D;
              aB = (SxB * Syy - SyB * Sxy) / D;
              bB = (SyB * Sxx - SxB * Sxy) / D;
          }

          // 近似したグラデーション平面を用いて欠損領域を塗りつぶし
          for (let idx of segmentPixels) {
              const x = idx % width;
              const y = Math.floor(idx / width);
              const dx = x - meanX;
              const dy = y - meanY;
              
              let r = aR * dx + bR * dy + meanR;
              let g = aG * dx + bG * dy + meanG;
              let b = aB * dx + bB * dy + meanB;
              
              // 0-255にクランプ
              r = Math.max(0, Math.min(255, r));
              g = Math.max(0, Math.min(255, g));
              b = Math.max(0, Math.min(255, b));
              
              // addColor内部で1回だけ加算するようにし、最終的に割る処理に備える
              // ※元のアルゴリズムでは縦横の2回加算して平均を取っていたが、今回は回数を1回とするため1回のみ呼び出し
              addColor(idx, r, g, b);
          }
      
          const result = new Map();
          colorMap.forEach((val, key) => {
              if (val.count > 0) {
                  result.set(key, {
                      r: val.r / val.count,
                      g: val.g / val.count,
                      b: val.b / val.count
                  });
              } else {
                  result.set(key, {r: 255, g: 255, b: 255});
              }
          });
          return result;
        }
      
        function createLayerFromSegment(segmentPixels, width, height, originalPixels, backgroundMap, transparencyThreshold) {
          let minX = width, maxX = 0, minY = height, maxY = 0;
          for (let i = 0; i < segmentPixels.length; i++) {
            const idx = segmentPixels[i];
            const x = idx % width;
            const y = Math.floor(idx / width);
            if (x < minX) minX = x;
            if (x > maxX) maxX = x;
            if (y < minY) minY = y;
            if (y > maxY) maxY = y;
          }
          const segWidth = maxX - minX + 1;
          const segHeight = maxY - minY + 1;
          
          const canvas = document.createElement('canvas');
          canvas.width = segWidth;
          canvas.height = segHeight;
          const ctx = canvas.getContext('2d');
          const imgData = ctx.createImageData(segWidth, segHeight);
          const data = imgData.data;
          
          let sumR = 0, sumG = 0, sumB = 0, count = 0;
      
          for (let i = 0; i < segmentPixels.length; i++) {
            const idx = segmentPixels[i];
            const x = idx % width;
            const y = Math.floor(idx / width);
            const srcI = idx * 4;
            const destI = ((y - minY) * segWidth + (x - minX)) * 4;
            
            const r = originalPixels[srcI];
            const g = originalPixels[srcI + 1];
            const b = originalPixels[srcI + 2];
            const a = originalPixels[srcI + 3];
      
            const bg = backgroundMap.get(idx) || {r: 0, g: 0, b: 0};
            const dist = Math.sqrt(Math.pow(r - bg.r, 2) + Math.pow(g - bg.g, 2) + Math.pow(b - bg.b, 2));
      
            if (dist < transparencyThreshold) {
              data[destI + 3] = 0; 
            } else {
              data[destI] = r;
              data[destI + 1] = g;
              data[destI + 2] = b;
              data[destI + 3] = a;
              sumR += r; sumG += g; sumB += b; count++;
            }
          }
          ctx.putImageData(imgData, 0, 0);
          
          const avgColor = count > 0 ? { r: sumR/count, g: sumG/count, b: sumB/count } : { r: 128, g: 128, b: 128 };
          
          return {
            x: minX,
            y: minY,
            width: segWidth,
            height: segHeight,
            avgColor: avgColor,
            dataBase64: canvas.toDataURL('image/png')
          };
        }
      
        function applyInpaintingToPixels(pixels, backgroundMap) {
            backgroundMap.forEach((color, idx) => {
                const baseI = idx * 4;
                pixels[baseI] = color.r;
                pixels[baseI + 1] = color.g;
                pixels[baseI + 2] = color.b;
                pixels[baseI + 3] = 255;
            });
        }
      
        async function process(imageElementOrCanvas, onStatusUpdate, options = {}, ocrData = null) {
          const params = { ...DEFAULT_PARAMS, ...options };
          
          const runCore = (currentOcrData) => {
            return new Promise((resolve, reject) => {
              setTimeout(() => {
                try {
                  const width = imageElementOrCanvas.width;
                  const height = imageElementOrCanvas.height;
                  const canvas = document.createElement('canvas');
                  canvas.width = width;
                  canvas.height = height;
                  const ctx = canvas.getContext('2d', { willReadFrequently: true });
                  ctx.drawImage(imageElementOrCanvas, 0, 0, width, height);
      
                  const mainImgData = ctx.getImageData(0, 0, width, height);
                  const pixels = mainImgData.data;
      
                  const layers = [];
                  const visitedObject = new Uint8Array(width * height);
      
                  if (currentOcrData && Array.isArray(currentOcrData)) {
                      if(onStatusUpdate) onStatusUpdate("JSONテキスト領域を解析中...");
                      
                      currentOcrData.forEach(item => {
                          if (!item.box) return;
                          
                          const box = item.box;
                          const originalBx = Math.floor(box.x);
                          const by = Math.floor(box.y);
                          const originalBw = Math.floor(box.width);
                          let bh = Math.floor(box.height);

                          // 開きカッコ（左15px拡張）と閉じカッコ（右15px拡張）、および文字切れ救済（右5px常に拡張）の調整
                          const text = item.text || "";
                          const trimmedText = text.trim();
                          const firstChar = trimmedText.charAt(0);
                          const lastChar = trimmedText.charAt(trimmedText.length - 1);
                          
                          // カンマが含まれる場合は下に3px拡張
                          if (text.includes(',') || text.includes('，')) {
                              bh += 3;
                              if (by + bh > height) {
                                  bh = Math.max(0, height - by);
                              }
                          }

                          // 開きカッコ（左側拡張の対象）
                          const isOpenBracket = /^[「『【（\(\[\{〈《〔〘〚]/.test(firstChar);
                          // 閉じカッコ（右側拡張の対象）
                          const isCloseBracket = /[」』】）\)\]\}〉》〕〙〛]$/.test(lastChar);

                          const leftExpand = isOpenBracket ? 15 : 0;
                          let bx = Math.max(0, originalBx - leftExpand);
                          let bw = originalBw + (originalBx - bx); // 左に広がった分だけ幅を増やす

                          const rightExpand = isCloseBracket ? 15 : 5;
                          bw = bw + rightExpand; // 閉じカッコなら15px、それ以外なら5px右に拡張
                          if (bx + bw > width) {
                            bw = Math.max(0, width - bx);
                          }

                          if (bx < 0 || by < 0 || bx + bw > width || by + bh > height || bw <= 0 || bh <= 0) return;
      
                          const subCanvas = document.createElement('canvas');
                          subCanvas.width = bw;
                          subCanvas.height = bh;
                          const subCtx = subCanvas.getContext('2d');
                          subCtx.drawImage(canvas, bx, by, bw, bh, 0, 0, bw, bh);
                          
                          const subImgData = subCtx.getImageData(0, 0, bw, bh);
                          const subPixels = subImgData.data;
                          
                          const subEdgeMap = detectEdges(subPixels, bw, bh, params.threshold);
                          const subBlockingMap = new Uint8Array(bw * bh);
                          for (let y = 1; y < bh - 1; y++) {
                            for (let x = 1; x < bw - 1; x++) {
                              const idx = y * bw + x;
                              if (subEdgeMap[idx] === 255) {
                                subBlockingMap[idx] = 255; 
                                subBlockingMap[idx - 1] = 255; subBlockingMap[idx + 1] = 255;
                                subBlockingMap[idx - bw] = 255; subBlockingMap[idx + bw] = 255;
                                subBlockingMap[idx - bw - 1] = 255; subBlockingMap[idx - bw + 1] = 255;
                                subBlockingMap[idx + bw - 1] = 255; subBlockingMap[idx + bw + 1] = 255;
                              }
                            }
                          }
      
                          const subLabels = new Uint8Array(bw * bh); 
                          const subQueue = [];
                          const subSeeds = [[0, 0], [bw-1, 0], [0, bh-1], [bw-1, bh-1]];
                          subSeeds.forEach(([sx, sy]) => {
                            const idx = sy * bw + sx;
                            if (subBlockingMap[idx] !== 255) { 
                                subQueue.push(idx); 
                                subLabels[idx] = 1; 
                            }
                          });
      
                          let head = 0;
                          while (head < subQueue.length) {
                            const idx = subQueue[head++];
                            const cx = idx % bw; 
                            const cy = Math.floor(idx / bw);
                            
                            const checkAndPush = (nIdx) => {
                              if (subLabels[nIdx] === 0 && subBlockingMap[nIdx] !== 255) {
                                subLabels[nIdx] = 1;
                                subQueue.push(nIdx);
                              }
                            };
                            if (cx > 0) checkAndPush(idx - 1);
                            if (cx < bw - 1) checkAndPush(idx + 1);
                            if (cy > 0) checkAndPush(idx - bw);
                            if (cy < bh - 1) checkAndPush(idx + bw);
                          }
      
                          const segmentPixelsGlobal = []; 
                          
                          for (let y = 0; y < bh; y++) {
                              for (let x = 0; x < bw; x++) {
                                  const localIdx = y * bw + x;
                                  if (subLabels[localIdx] !== 1) {
                                      const globalX = bx + x;
                                      const globalY = by + y;
                                      const globalIdx = globalY * width + globalX;
                                      
                                      if (pixels[globalIdx * 4 + 3] !== 0) {
                                          segmentPixelsGlobal.push(globalIdx);
                                      }
                                  }
                              }
                          }
      
                          if (segmentPixelsGlobal.length > 0) {
                              const bgMap = calculateInpaintedColors(pixels, width, height, segmentPixelsGlobal);
                              const layer = createLayerFromSegment(segmentPixelsGlobal, width, height, pixels, bgMap, params.transparencyThreshold);
                              
                              layer.ocrData = item;
                              
                              layers.push(layer);
                              applyInpaintingToPixels(pixels, bgMap);
                          }
                      });
                  }
      
                  if(onStatusUpdate) onStatusUpdate("その他の要素を解析中...");
                  const edgeMap = detectEdges(pixels, width, height, params.threshold);
                  const blockingMap = new Uint8Array(width * height);
                  for (let y = 1; y < height - 1; y++) {
                    for (let x = 1; x < width - 1; x++) {
                      const idx = y * width + x;
                      if (edgeMap[idx] === 255) {
                        blockingMap[idx] = 255; 
                        blockingMap[idx - 1] = 255; blockingMap[idx + 1] = 255;
                        blockingMap[idx - width] = 255; blockingMap[idx + width] = 255;
                        blockingMap[idx - width - 1] = 255; blockingMap[idx - width + 1] = 255;
                        blockingMap[idx + width - 1] = 255; blockingMap[idx + width + 1] = 255;
                      }
                    }
                  }
      
                  const labels = new Uint8Array(width * height); 
                  const queue = [];
                  const seeds = [[0, 0], [width-1, 0], [0, height-1], [width-1, height-1]];
                  seeds.forEach(([sx, sy]) => {
                    const idx = sy * width + sx;
                    if (visitedObject[idx] === 0 && blockingMap[idx] !== 255) { 
                        queue.push(idx); 
                        labels[idx] = 1; 
                    }
                  });
      
                  let head = 0;
                  while (head < queue.length) {
                    const idx = queue[head++];
                    const cx = idx % width; 
                    const cy = Math.floor(idx / width);
                    const checkAndPush = (nIdx) => {
                      if (visitedObject[nIdx] === 0 && labels[nIdx] === 0 && blockingMap[nIdx] !== 255) {
                        labels[nIdx] = 1;
                        queue.push(nIdx);
                      }
                    };
                    if (cx > 0) checkAndPush(idx - 1);
                    if (cx < width - 1) checkAndPush(idx + 1);
                    if (cy > 0) checkAndPush(idx - width);
                    if (cy < height - 1) checkAndPush(idx + width);
                  }
      
                  if(onStatusUpdate) onStatusUpdate("要素を抽出中...");
                  
                  for (let y = 0; y < height; y+=2) { 
                    for (let x = 0; x < width; x+=2) {
                      const idx = y * width + x;
                      if (labels[idx] !== 1 && pixels[idx*4+3] !== 0 && visitedObject[idx] === 0) {
                        const segmentPixels = [];
                        const objQueue = [idx];
                        visitedObject[idx] = 1; 
                        
                        let qHead = 0;
                        while (qHead < objQueue.length) {
                          const currIdx = objQueue[qHead++];
                          segmentPixels.push(currIdx);
                          const cx = currIdx % width; 
                          const cy = Math.floor(currIdx / width);
                          const checkObj = (nIdx) => {
                            if (visitedObject[nIdx] === 0 && labels[nIdx] !== 1 && pixels[nIdx*4+3] !== 0) {
                              visitedObject[nIdx] = 1;
                              objQueue.push(nIdx);
                            }
                          };
                          if (cx > 0) checkObj(currIdx - 1);
                          if (cx < width - 1) checkObj(currIdx + 1);
                          if (cy > 0) checkObj(currIdx - width);
                          if (cy < height - 1) checkObj(currIdx + width);
                        }
                        
                        if (segmentPixels.length > params.minSegmentPixels) { 
                          const bgMap = calculateInpaintedColors(pixels, width, height, segmentPixels);
                          const layer = createLayerFromSegment(segmentPixels, width, height, pixels, bgMap, params.transparencyThreshold);
                          layers.push(layer);
                          applyInpaintingToPixels(pixels, bgMap);
                        }
                      }
                    }
                  }
      
                  ctx.putImageData(mainImgData, 0, 0);
                  const bgBase64 = canvas.toDataURL('image/png');
      
                  resolve({
                    background: bgBase64,
                    layers: layers,
                    originalWidth: width,
                    originalHeight: height
                  });
                } catch (e) {
                  reject(e);
                }
              }, 50);
            });
          };

          if(onStatusUpdate) onStatusUpdate("画像解析中 (1/2)...");
          const firstResult = await runCore(ocrData);

          if (!ocrData || ocrData.length === 0) {
              return firstResult;
          }

          const textLayers = firstResult.layers.filter(l => l.ocrData);
          const illustLayers = firstResult.layers.filter(l => !l.ocrData);
          const width = firstResult.originalWidth;
          const height = firstResult.originalHeight;

          const validOcrData = [];
          const excludedOcrItems = [];

          for (const tLayer of textLayers) {
              let enclosedByIllust = false;
              for (const iLayer of illustLayers) {
                  if (iLayer.width < width * 0.3 && iLayer.height < height * 0.3) {
                      if (tLayer.x >= iLayer.x &&
                          tLayer.y >= iLayer.y &&
                          tLayer.x + tLayer.width <= iLayer.x + iLayer.width &&
                          tLayer.y + tLayer.height <= iLayer.y + iLayer.height) {
                          enclosedByIllust = true;
                          break;
                      }
                  }
              }
              if (enclosedByIllust) {
                  excludedOcrItems.push({
                      ocr: tLayer.ocrData,
                      reason: "幅・高さが30%未満のイラストに完全に内包されるため、イラストとして判定"
                  });
              } else {
                  validOcrData.push(tLayer.ocrData);
              }
          }

          if (excludedOcrItems.length === 0) {
              return firstResult;
          }

          if(onStatusUpdate) onStatusUpdate("包含テキストを除外し、再構築中...");
          const secondResult = await runCore(validOcrData);

          for (const excluded of excludedOcrItems) {
              const box = excluded.ocr.box;
              if (!box) continue;
              const tx = box.x + box.width / 2;
              const ty = box.y + box.height / 2;
              
              for (const layer of secondResult.layers) {
                  if (!layer.ocrData) { // is illustration
                      if (tx >= layer.x && ty >= layer.y && tx <= layer.x + layer.width && ty <= layer.y + layer.height) {
                          layer.is_illustration = true;
                          layer.reason = excluded.reason;
                          // Found the matching illustration
                          break;
                      }
                  }
              }
          }

          return secondResult;
        }
      
        return { process: process };
        })();
    }

    /**
     * 画像をレイヤーに分解します
     * @param {HTMLImageElement|HTMLCanvasElement} imageElementOrCanvas 
     * @param {Array} ocrJsonData TextProcessingEngineから得たJSON配列
     * @param {Object} options { threshold, minSegmentPixels, transparencyThreshold }
     * @param {Function} onProgress 進捗コールバック (statusString) => void
     * @returns {Promise<Object>} { layers: Array, background: String(DataURL) }
     */
    async processImageToLayers(imageElementOrCanvas, ocrJsonData = null, options = {}, onProgress = null) {
        return await this._engine.process(imageElementOrCanvas, onProgress, options, ocrJsonData);
    }

    /**
     * 領域画像をレイヤーに分解します（単独処理用ラッパー）
     * processImageToLayers と同じコアロジックを使用するため、メインロジック変更時に自動追従します。
     * @param {HTMLCanvasElement} canvasElement - 切り出し済みの領域画像Canvas
     * @param {Array} ocrJsonData - OCR結果のJSON配列
     * @param {Object} options - オプション
     * @param {Function} onProgress - 進捗コールバック
     * @returns {Promise<Object>} { layers, background }
     */
    async processRegionToLayers(canvasElement, ocrJsonData = null, options = {}, onProgress = null) {
        return await this.processImageToLayers(canvasElement, ocrJsonData, options, onProgress);
    }
}

window.ImageProcessingEngine = ImageProcessingEngine;

/* ===== END ImageProcessingEngine.js ===== */
  // [majin-build] 自己 export(window.X=) を持たないエンジン(class 宣言のみ。例: PptxExportEngine)でも
  //   クラス束縛を overlay 経由で window.NLM.ImageProcessingEngine に確実に載せる(自己 export 済みなら冪等な二重代入)。
  try { if (typeof ImageProcessingEngine !== 'undefined') { window.ImageProcessingEngine = ImageProcessingEngine; } } catch (e) {}
  })(__win);
  __realWindow.NLM.ImageProcessingEngine = __overlay.ImageProcessingEngine; // エンジンの window.ImageProcessingEngine= は overlay に入る
})(window);
  </script>
  <script id="nlm-pptx-engine">
;(function (__realWindow) {
  if (!__realWindow.NLM) __realWindow.NLM = {};
  // === サンドボックス: window を Proxy 化(原本エンジンは無改変で読み書きできる) ===
  var __UI_EVENTS = { paste: 1, resize: 1, DOMContentLoaded: 1 };
  var __overlay = Object.create(null); // エンジンが書く window.X はここに隔離(実window非汚染)
  var __win = new Proxy(__realWindow, {
    get: function (t, p) {
      if (p in __overlay) return __overlay[p];
      if (p === 'addEventListener') {
        return function (type, fn, opts) { if (__UI_EVENTS[type]) return; return t.addEventListener(type, fn, opts); };
      }
      var v = Reflect.get(t, p); // 受け手=実window: getter(document/navigator等)の this が実windowになり Illegal invocation を回避
      return (typeof v === 'function') ? v.bind(t) : v;
    },
    set: function (t, p, val) { __overlay[p] = val; return true; },
    has: function (t, p) { return (p in __overlay) || (p in t); }
  });
  (function (window) {
/* ===== BEGIN PptxExportEngine.js (verbatim copy from nlm_modules/PptxExportEngine.js + build時 majin-patch) ===== */
/**
 * PptxExportEngine.js
 * PowerPoint (PPTX) 出力機能をカプセル化した外部エンジンクラス。
 * PptxGenJS ライブラリに依存します。
 */
class PptxExportEngine {
  /**
   * PPTXファイルを生成してダウンロードします。
   * @param {Object} options
   * @param {HTMLImageElement} options.originalImage - 元画像
   * @param {string} options.originalImageData - 背景画像（DataURL形式）
   * @param {Array} options.detectedElements - 検出された要素の配列
   * @param {Object} [options.backgroundColor] - スライド自体の背景色 ({r, g, b} 形式)
   * @param {string} [options.fileName] - 出力するPPTXファイル名
   */
  static async export(options) {
    const { 
      pages = [], 
      backgroundColor = { r: 255, g: 255, b: 255 }, 
      fileName = 'NotebookLM_Infographic.pptx' 
    } = options;
    
    if (pages.length === 0) {
      // 互換性のため、古い形式でも動作するようにする
      if (options.originalImage && options.detectedElements) {
        pages.push({
          originalImage: options.originalImage,
          originalImageData: options.originalImageData,
          detectedElements: options.detectedElements
        });
      } else {
        throw new Error('処理するページが指定されていません。');
      }
    }

    // PptxGenJSがグローバルに読み込まれていることを確認
    if (typeof PptxGenJS === 'undefined') {
      throw new Error('PptxGenJS ライブラリが読み込まれていません。script タグを確認してください。');
    }

    const pptx = new PptxGenJS();
    
    // 最初のページのサイズを基準にスライドサイズを設定
    const firstPage = pages[0];
    const baseW = firstPage.originalImageWidth || (firstPage.originalImage && firstPage.originalImage.width) || 1920;
    const baseH = firstPage.originalImageHeight || (firstPage.originalImage && firstPage.originalImage.height) || 1080;
    const slideW = 13.33;
    const slideH = slideW * (baseH / baseW);
    pptx.defineLayout({ name: 'Custom', width: slideW, height: slideH });
    pptx.layout = 'Custom';
    
    for (const page of pages) {
      const { originalImage, originalImageWidth, originalImageHeight, originalImageData, detectedElements } = page;
      if (!detectedElements) continue;

      const slide = pptx.addSlide();
      
      const iw = originalImageWidth || (originalImage && originalImage.width);
      const ih = originalImageHeight || (originalImage && originalImage.height);
      
      // 0. スライドの背景色の適用 (画像の背景色をスライドに完全反映)
      const bgHex = this.rgbToHex(Math.round(backgroundColor.r), Math.round(backgroundColor.g), Math.round(backgroundColor.b));
      slide.background = { fill: bgHex };
      
      // 1. 背景画像の追加
      if (originalImageData && typeof originalImageData === 'string') {
        slide.addImage({ data: originalImageData, x: 0, y: 0, w: slideW, h: slideH });
      } else {
        const bgCanvas = document.createElement('canvas');
        bgCanvas.width = iw;
        bgCanvas.height = ih;
        bgCanvas.getContext('2d', { colorSpace: 'srgb' }).drawImage(originalImage, 0, 0);
        slide.addImage({ data: bgCanvas.toDataURL('image/png'), x: 0, y: 0, w: slideW, h: slideH });
      }
      
      // インチ変換スケールファクター (画像サイズとスライドサイズのマッピング)
      const sx = slideW / iw;
      const sy = slideH / ih;
      
      // ===================================================
      // 高度なフォントサイズ安定化：同一高さのテキストのグループ化と平均化
      // ===================================================
      // 1. テキスト要素のみを抽出
      const textElements = detectedElements.filter(el => el.type === 'text' && el.ocrText && el.ocrText.length > 0);

      // 2. 画像の高さに対する割合（％、四捨五入）に基づくグループ化
      const heightPercentGroups = {};

      textElements.forEach(el => {
        const pct = Math.round((el.height / ih) * 100);
        if (!heightPercentGroups[pct]) {
          heightPercentGroups[pct] = [];
        }
        heightPercentGroups[pct].push(el);
      });

      // 3. 各グループごとに平均文字数・幅・高さから共通フォントサイズを算出して適用
      Object.values(heightPercentGroups).forEach(group => {
        let totalVisualLength = 0;
        let totalWIn = 0;
        let totalHIn = 0;
        
        group.forEach(el => {
          const visualLength = [...el.ocrText].reduce((acc, char) => acc + (char.match(/[ -~\uff61-\uff9f]/) ? 0.55 : 1.0), 0);
          totalVisualLength += visualLength;
          
          const elementHIn = el.height * sy;
          let elementWIn = el.width * sx;
          
          totalWIn += elementWIn;
          totalHIn += elementHIn;
        });
        
        const avgVisualLength = totalVisualLength / group.length;
        const avgWIn = totalWIn / group.length;
        const avgHIn = totalHIn / group.length;
        
        let fitFontPt = Math.floor(65 * Math.sqrt((avgWIn * avgHIn) / Math.max(1, avgVisualLength)));
        
        const maxHeightPt = Math.round(avgHIn * 72 * 0.82);
        if (fitFontPt > maxHeightPt) {
          fitFontPt = maxHeightPt;
        }
        
        const finalFontPt = Math.max(8, fitFontPt);
        
        group.forEach(el => {
          el.computedFontSize = finalFontPt;
        });
      });

      // 2. 各要素の追加 (Z-Indexの制御: テキストが必ず前面にくるように、先にすべてのイラストを追加し、その後にすべてのテキストを追加)
      // 2.1 イラスト（画像等）を先に追加 ※showOriginalがtrueのテキスト要素も画像として配置
      for (const el of detectedElements) {
        const isImageExport = (el.type !== 'text' && (el.cropCanvas || el.dataBase64)) || (el.type === 'text' && el.showOriginal && (el.cropCanvas || el.dataBase64));
        if (isImageExport) {
          const xIn = el.x * sx;
          const yIn = el.y * sy;
          const wIn = el.width * sx;
          const hIn = el.height * sy;
          const imgData = el.cropCanvas ? el.cropCanvas.toDataURL('image/png') : el.dataBase64;
          slide.addImage({ data: imgData, x: xIn, y: yIn, w: wIn, h: hIn });
        }
      }

      // 2.2 テキスト要素を後から（前面に）追加
      const measureCanvas = document.createElement('canvas');
      const measureCtx = measureCanvas.getContext('2d');

      for (const el of detectedElements) {
        if (el.type === 'text' && el.ocrText && el.ocrText.length > 0 && !el.showOriginal) {
          const xIn = el.x * sx;
          const yIn = el.y * sy;
          const hIn = el.height * sy;
          
          let estFontPt = el.computedFontSize;
          if (!estFontPt) {
            const wInApprox = el.width * sx;
            const visualLength = [...el.ocrText].reduce((acc, char) => acc + (char.match(/[ -~\uff61-\uff9f]/) ? 0.55 : 1.0), 0);
            let fitFontPt = Math.floor(65 * Math.sqrt((wInApprox * hIn) / Math.max(1, visualLength)));
            const maxHeightPt = Math.round(hIn * 72 * 0.82);
            if (fitFontPt > maxHeightPt) fitFontPt = maxHeightPt;
            estFontPt = Math.max(8, fitFontPt);
          }

          const fontSizePx = estFontPt * (96 / 72);
          measureCtx.font = `bold ${fontSizePx}px "MS P Gothic", sans-serif`;
          const textMetrics = measureCtx.measureText(el.ocrText);
          const wIn = (textMetrics.width / 96) * 1.02;

          const tc = el.avgColor || { r: 0, g: 0, b: 0 };
          const hexColor = PptxExportEngine.rgbToHex(Math.round(tc.r), Math.round(tc.g), Math.round(tc.b));
          
          slide.addText(el.ocrText, {
            x: xIn,
            y: yIn,
            w: wIn,
            h: hIn,
            fontSize: estFontPt,
            fontFace: 'MS P Gothic',
            bold: true,
            color: hexColor === 'ffffff' ? (bgHex === 'ffffff' ? '333333' : 'ffffff') : hexColor,
            valign: 'middle',
            wrap: true,
            shrinkText: true,
            margin: 0
          });
        }
      }
    }
    
    if (PptxExportEngine.__returnBase64) { return await pptx.write('base64'); } /* [majin-patch] exportBase64経路: DLせずbase64返却(PptxGenJS3.12は文字列引数) */
    await pptx.writeFile({ fileName });
  }
  
  /**
   * RGB値をHEX文字列に変換します。
   */
  /* [majin-patch] ダウンロードせずPPTXをbase64で返す。export()本体を共有(レイアウト等価)。単一ユーザ操作前提で再入なし。 */
  static async exportBase64(options) {
    PptxExportEngine.__returnBase64 = true;
    try { return await PptxExportEngine.export(options); }
    finally { PptxExportEngine.__returnBase64 = false; }
  }

  static rgbToHex(r, g, b) {
    return [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
  }
}

/* ===== END PptxExportEngine.js ===== */
  // [majin-build] 自己 export(window.X=) を持たないエンジン(class 宣言のみ。例: PptxExportEngine)でも
  //   クラス束縛を overlay 経由で window.NLM.PptxExportEngine に確実に載せる(自己 export 済みなら冪等な二重代入)。
  try { if (typeof PptxExportEngine !== 'undefined') { window.PptxExportEngine = PptxExportEngine; } } catch (e) {}
  })(__win);
  __realWindow.NLM.PptxExportEngine = __overlay.PptxExportEngine; // エンジンの window.PptxExportEngine= は overlay に入る
})(window);
  </script>
  <!-- ===== NLM_ENGINES_END ===== -->
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
  <style>
    /* --- [新規追加] プレビューローディングアニメーション --- */

/**
 * 1. アニメーションの定義 (0% -> 100%)
 * 90秒かけて、ぼかし(blur)とグレースケール(grayscale)を解除し、
 * 最後の3秒間 (96.7% -> 100%) でフェードアウトします。
 */
@keyframes revealSlide {
  /* 開始時 (0%) */
  0% {
/* 強いぼかし + グレースケール */
    backdrop-filter: blur(20px) grayscale(100%);
    -webkit-backdrop-filter: blur(20px) grayscale(100%);
background-color: rgba(50, 50, 50, 0.3);
    opacity: 1; /* 不透明 */
}
  
  /* ▼▼▼ [変更] 95% -> 96.7% (87秒時点) ▼▼▼ */
  /* 87秒かけてぼかしを解除し、この時点までは不透明を維持 */
96.7% {
    backdrop-filter: blur(0px) grayscale(0%);
    -webkit-backdrop-filter: blur(0px) grayscale(0%);
background-color: rgba(200, 200, 200, 0.1);
    opacity: 1; /* ◀ 96.7%時点 (87秒) ではまだ不透明 */
}

  /* ▼▼▼ [変更] 100% (90秒時点) ▼▼▼ */
  /* 最後の3秒間 (87秒〜90秒) で opacity を 0 にしてフェードアウト */
100% {
    backdrop-filter: blur(0px) grayscale(0%);
    -webkit-backdrop-filter: blur(0px) grayscale(0%);
background-color: rgba(200, 200, 200, 0);
    opacity: 0; /* ◀ 100%時点 (90秒) で透明に */
}
}

/**
 * 2. #previewOverlay の基本スタイル
 * (HTML内のインラインスタイル を上書き)
 */
#previewOverlay {
  display: none; /* 初期状態は非表示 */
position: absolute;
  top: 0;
  left: 0;
width: 100%;
  height: 100%;
  z-index: 20;
cursor: wait;
  
  /* ▼▼▼ [変更] フィルターの初期状態とトランジション ▼▼▼ */
  
  /* アニメーションの初期状態 (0%の状態) を定義 */
  backdrop-filter: blur(20px) grayscale(100%);
  -webkit-backdrop-filter: blur(20px) grayscale(100%);
  background-color: rgba(50, 50, 50, 0.3);
  
  /* * opacity (不透明度) を 0 に設定 (非表示時のデフォルト)
   * opacity の変化に 3秒間のトランジション (フェード) を設定
   */
  opacity: 0;
transition: opacity 3s ease-out; /* ◀ 3秒フェードアウト */
  
  /* アニメーションが適用されていない時は、即座に非表示に戻る */
  animation: none;
}

/**
 * 3. アニメーションを実行するためのクラス
 * (JavaScriptからこのクラスを追加/削除します)
 */
#previewOverlay.loading {
  display: block !important; /* 表示を強制 */
  
  /* ▼▼▼ [変更] アニメーション開始時に opacity を 1 にする ▼▼▼ */
  opacity: 1;

  /* * アニメーションを実行
   * 1. アニメーション名: revealSlide
   * 2. 所要時間: 90s (ユーザー指定) ◀ 90秒に変更
   * 3. 速度: linear (一定速度)
   * 4. 回数: 1
   * 5. 完了後の状態: forwards (100%の状態で停止)
   */
  animation: revealSlide 90s linear 1 forwards; /* ◀ 90s に変更 */
}

    /* --- [追加] 情報ボタン(ⓘ): ホバーで「押せそう」なアニメ→クリックでモーダル表示（ホバー吹き出しは廃止＝見切れ回避） --- */
    .info-tip { position: relative; display: inline-flex; align-items: center; justify-content: center;
      width: 1.05rem; height: 1.05rem; margin-left: 0.35rem; border-radius: 50%;
      background: var(--secondary-color, #6B7280); color: #fff; font-size: 0.72rem; font-weight: 700;
      line-height: 1; cursor: pointer; vertical-align: middle; user-select: none; flex-shrink: 0;
      transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; }
    /* ホバー/フォーカス: 拡大＋プライマリ色＋発光リング＋パルスで「クリックできそう」を表現してクリックを誘導 */
    .info-tip:hover, .info-tip:focus { background-color: var(--primary-color, #4285F4); transform: scale(1.22);
      box-shadow: 0 0 0 4px rgba(66,133,244,0.22); outline: none; animation: infoTipPulse 1.1s ease-in-out infinite; }
    @keyframes infoTipPulse { 0%, 100% { box-shadow: 0 0 0 3px rgba(66,133,244,0.30); } 50% { box-shadow: 0 0 0 7px rgba(66,133,244,0.10); } }
    /* 説明本文は常時非表示（DOMには保持＝クリック時にモーダルが読む）。マウスオーバーでの吹き出しは出さない。 */
    .info-tip > .info-tip-text { display: none !important; }
    /* クリック時の本文確認モーダル */
    #infoTipModalBody strong { color: var(--text-large-font, #111827); }
    /* --- [追加] テンプレート余白(px)を1行にまとめるコンパクト行 --- */
    .margin-compact-row { display: flex; align-items: flex-end; gap: 0.5rem; margin-top: 0.5rem; }
    .margin-compact-row .margin-cell { flex: 1 1 0; min-width: 0; }
    .margin-compact-row .margin-cell label { display: block; font-size: 0.72rem; color: var(--text-secondary, #4B5563); margin-bottom: 0.15rem; }
    .margin-compact-row .margin-cell input { width: 100%; }

    /* --- CSSは変更なしのため省略 --- */
    :root {
      --primary-color: #4285F4; 
      --primary-color-dark: #3871CF; 
      --secondary-color: #6B7280;
      --secondary-color-dark: #4B5563;
      --danger-color: #EF4444;
      --danger-color-dark: #B91C1C;
      --success-color: #10B981;

      --text-large-font: #333333; 
      --text-small-font: #1F2937;
      --text-secondary: #4B5563;
      --text-light: #9CA3AF;
      --text-on-primary: #FFFFFF;
      --card-header-bg: #F9FAFB;
      --bg-body: #F9FAFB;
      --bg-surface: #FFFFFF;
      --border-color: #D1D5DB;
      --border-focus-color: var(--primary-color); 
      /* Static Notification Colors */
      --toast-success-bg: #F0FDF4; --toast-success-text: #15803D; --toast-success-border: #A7F3D0;
      --toast-error-bg: #FEF2F2; --toast-error-text: #B91C1C;
      --toast-error-border: #FECACA;
      /* ★[N2] warning(注意・処理は続行) 黄系。border は #FCD34D 維持 */
      --toast-warning-bg: #FEF3C7; --toast-warning-text: #92400E; --toast-warning-border: #FCD34D;
      
      --font-family-base: 'Inter', 'Noto Sans JP', sans-serif;
      --font-family-code: 'Fira Code', 'Consolas', monospace;
      
      --radius-sm: 4px;
      --radius-md: 8px;
      --radius-lg: 12px; 
      --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
      --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 
    }

    /* --- Base Styles --- */
    *, *::before, *::after { box-sizing: border-box; 
    }
    
    body {
      font-family: var(--font-family-base);
      margin: 0; 
      padding: 0;
      background-color: var(--bg-body);
      color: var(--text-small-font);
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale; 
      /* ▼▼▼ [変更] 画面全体を埋めるようにする ▼▼▼ */
      display: flex;
      flex-direction: column;
      height: 100vh;
      overflow: hidden; 
      /* ▲▲▲ [変更] ここまで ▲▲▲ */
    }

    /* --- Layout --- */
    .container {
      /* max-width: 1400px; */ 
      width: 100%; 
      margin: 0 auto;
      padding: 1.5rem; 
      flex-grow: 1; 
    }

    .main-grid {
      display: grid; 
      grid-template-columns: 1.5fr 1fr;
      gap: 2rem;
      align-items: start;
    }

    .left-column {
      display: flex; 
      flex-direction: column;
      gap: 2rem;
    }

    /* --- Card Component --- */
    .card {
      background: var(--bg-surface); 
      border-radius: var(--radius-lg);
      box-shadow: var(--shadow-md);
      overflow: hidden;
      border: 1px solid var(--border-color); 
    }
    .card-header {
      font-size: 1.25rem;
      font-weight: 600;
      padding: 1rem 1.5rem;
      background-color: var(--card-header-bg); 
      color: var(--text-large-font);
      border-bottom: 1px solid var(--border-color);
      margin: 0;
      border-radius: var(--radius-lg) var(--radius-lg) 0 0; 
    }
    .card-body {
      padding: 1.5rem; 
    }

    /* --- Form Elements --- */
    textarea#slideDataInput {
      width: 100%;
      height: 250px;
      visibility: hidden;
      transition: visibility 0.1s ease-in;
      font-family: var(--font-family-code);
      font-size: 0.875rem;
      line-height: 1.6; /* ← ★追加★ 行間を広げる */
      border: 1px solid var(--border-color);
      border-radius: var(--radius-md);
      padding: 1rem;
      background-color: var(--bg-surface); 
      transition: border-color 0.2s, box-shadow 0.2s;
      resize: vertical;
    }


    textarea#slideDataInput:focus {
      outline: none; 
      border-color: var(--border-focus-color);
      box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); 
    }
    
    .form-grid {
      display: grid;
      grid-template-columns: 1fr 1fr; 
      gap: 1.25rem;
      align-items: end;
    }
    .grid-col-full {
        grid-column: 1 / -1; 
    }
    
    .form-group label {
      display: block;
      font-weight: 500; 
      font-size: 0.875rem;
      color: var(--text-secondary);
      margin-bottom: 0.5rem;
    }
    
    .form-control {
      width: 100%; 
      padding: 0.75rem 1rem;
      border: 1px solid var(--border-color);
      border-radius: var(--radius-md);
      font-size: 0.875rem;
      transition: all 0.2s ease;
      background: var(--bg-surface); 
    }
    
    .form-control:focus {
      outline: none;
      border-color: var(--border-focus-color); 
      box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
    }
    
    .input-group {
      display: flex; 
      gap: 0.5rem;
      align-items: center;
    }
    .input-group .form-control {
      flex: 1; 
    }
    
    input[type="color"] {
      -webkit-appearance: none;
      -moz-appearance: none; 
      appearance: none;
      width: 44px;
      height: 44px;
      padding: 0;
      border: 1px solid var(--border-color);
      border-radius: var(--radius-md);
      cursor: pointer; 
    }
    input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
    input[type="color"]::-webkit-color-swatch { border: none; border-radius: var(--radius-md); 
    }

    .checkbox-wrapper {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      padding: 0.5rem 0; 
    }
    .checkbox-wrapper label {
      margin-bottom: 0;
      color: var(--text-primary);
      font-weight: 400; 
    }
    input[type="checkbox"] {
      width: 18px;
      height: 18px;
      accent-color: var(--primary-color); 
    }

    /* --- Buttons --- */
    .btn {
      display: inline-flex; 
      align-items: center;
      justify-content: center;
      width: 100%;
      padding: 0.875rem 1rem;
      font-size: 1rem;
      font-weight: 600;
      border: none;
      border-radius: var(--radius-md);
      cursor: pointer; 
      transition: all 0.2s ease;
      text-transform: none;
      letter-spacing: 0.025em;
    }

    .btn:focus, .btn:focus-visible {
      outline: none; 
      box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); 
    }

    a.btn {
      text-decoration: none; 
    }
    .btn:disabled {
      opacity: 0.6;
      cursor: not-allowed; 
    }
    .btn-primary {
      background-color: var(--primary-color);
      color: var(--text-on-primary);
      box-shadow: var(--shadow-sm); 
    }
    .btn-primary:hover:not(:disabled) {
      background-color: var(--primary-color-dark);
      transform: translateY(-1px); 
    }
    .btn-preset {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      border: none; 
      cursor: pointer;
      transition: all 0.2s ease;
      font-weight: 600;
      letter-spacing: 0.025em; 
      text-transform: none;
      padding: 0.5rem 1rem;
      font-size: 0.875rem;
      background-color: var(--primary-color);
      color: var(--text-on-primary);
      border-radius: var(--radius-sm);
      white-space: nowrap; 
    }
    .btn-preset:hover:not(:disabled) { background-color: var(--primary-color-dark); }
    
    .btn-secondary {
      display: inline-flex; 
      align-items: center;
      justify-content: center;
      border: none; 
      cursor: pointer; 
      transition: all 0.2s ease;
      font-weight: 600;
      letter-spacing: 0.025em;
      text-transform: none;
      padding: 0.5rem 0.75rem;
      font-size: 0.75rem;
      background-color: var(--secondary-color);
      color: var(--text-on-primary); 
      border-radius: var(--radius-sm);
      white-space: nowrap;
    }
    .btn-secondary:hover:not(:disabled) { background-color: var(--secondary-color-dark); 
    }
    
    .button-group {
      margin-top: 1.5rem;
      display: flex;
      gap: 1rem;
      flex-wrap: wrap; /* 画面が狭い場合に折り返す */
      align-items: center;
    }
    
    /* --- Collapsible Section --- */
    .collapsible-section { margin: 1.25rem 0; 
    }
    .collapsible-header {
      background: var(--bg-surface);
      color: var(--text-secondary);
      padding: 0.75rem 1rem;
      border-radius: var(--radius-md); 
      cursor: pointer;
      font-weight: 600;
      font-size: 1rem;
      border: 1px solid var(--border-color);
      transition: all 0.2s ease;
      display: flex;
      justify-content: space-between;
      align-items: center; 
    }
    .collapsible-header:hover, .collapsible-header.active {
      background: #EFF6FF;
      color: var(--primary-color); 
    }
    .collapsible-content {
      display: none;
      padding: 1.25rem;
      background: var(--bg-surface); 
      border: 1px solid var(--border-color);
      border-top: none;
      border-radius: 0 0 var(--radius-md) var(--radius-md);
    }
    .collapsible-content.active { display: block; 
    }
    .collapsible-icon { transition: transform 0.2s ease; }
    .collapsible-header.active .collapsible-icon { transform: rotate(180deg); 
    }

    /* --- Note / Info Boxes --- */
    .note {
        margin-bottom: 1rem; 
        padding: 0.75rem 1rem;
        border-radius: var(--radius-md);
        font-size: 0.875rem;
        line-height: 1.5;
        border: 1px solid transparent; 
    }
    .note-info { background-color: #EFF6FF; border-color: #BFDBFE; color: #1E3A8A; }
    .note-warning { background-color: #FFFBEB; 
      border-color: #FDE68A; color: #92400E; display: none; margin-top: 0.5rem; }

    /* --- Preset Management --- */
    .preset-management {
      margin-bottom: 1.5rem; 
      padding: 1rem;
      background: #F9FAFB;
      border-radius: var(--radius-md);
      border: 1px solid var(--border-color); 
    }
    .preset-controls label {
      font-weight: 600;
      margin-bottom: 0.5rem;
      color: var(--text-small-font);
      font-size: 0.875rem; 
    }
    .preset-control-row { display: flex; gap: 0.5rem; align-items: center; 
    }
    .preset-menu-wrapper {
      position: relative;
      flex-shrink: 0;
    }
    .preset-menu-button {
      width: 2.25rem;
      min-width: 2.25rem;
      padding-left: 0;
      padding-right: 0;
      font-size: 1rem;
      line-height: 1;
    }
    .preset-action-menu {
      position: absolute;
      top: calc(100% + 0.35rem);
      right: 0;
      z-index: 30;
      min-width: 10rem;
      padding: 0.35rem;
      background: var(--bg-surface);
      border: 1px solid var(--border-color);
      border-radius: var(--radius-sm);
      box-shadow: var(--shadow-lg);
    }
    .preset-action-menu[hidden] {
      display: none;
    }
    .preset-menu-item {
      display: flex;
      align-items: center;
      width: 100%;
      padding: 0.55rem 0.7rem;
      border: none;
      border-radius: var(--radius-sm);
      background: transparent;
      color: var(--text-primary);
      cursor: pointer;
      font-size: 0.875rem;
      font-weight: 500;
      text-align: left;
      white-space: nowrap;
    }
    .preset-menu-item:hover:not(:disabled) {
      background: #EFF6FF;
      color: var(--primary-color);
    }
    .preset-menu-item:disabled {
      color: var(--text-secondary);
      cursor: not-allowed;
      opacity: 0.55;
    }
    .preset-select {
      flex: 1;
      padding: 0.5rem 0.75rem;
      border: 1px solid var(--border-color); 
      border-radius: var(--radius-sm);
      font-size: 0.875rem;
      background: var(--bg-surface);
    }

    /* --- Gradient Options --- */
    #gradient-options { display: none; 
      border-top: 1px solid var(--border-color); margin-top: 1.25rem; padding-top: 1.25rem; }
    .gradient-preview-section { margin-top: 1.25rem; 
    }
    .gradient-preview-box {
      height: 50px;
      border-radius: var(--radius-md);
      display: flex;
      align-items: center; 
      justify-content: center;
      color: white;
      font-weight: 600;
      margin-top: 0.5rem;
      border: 1px solid var(--border-color);
      transition: all 0.3s ease; 
    }

    /* --- JSON Validation --- */
    .json-validation {
      margin-bottom: 1rem; 
      padding: 1rem;
      border-radius: var(--radius-md);
      background: #F9FAFB;
      border: 1px solid var(--border-color); 
    }
    .validation-status {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      font-size: 0.875rem; 
      font-weight: 500;
      justify-content: flex-start;
      width: 100%;
    }
    .validation-details {
      margin-top: 0.75rem; 
      font-size: 0.8125rem;
      line-height: 1.4;
      padding: 0.5rem 0.75rem;
      border-radius: var(--radius-sm);
      display: none;
    }
    .validation-error { background: #FEF2F2; 
      border: 1px solid #FECACA; color: #B91C1C; }
    .validation-warning { background: #FFFBEB; border: 1px solid #FDE68A; color: #92400E; 
    }
    
    /* --- Toast Notifications (New Style) --- */
    .toast-container {
      position: fixed; 
      bottom: 1.5rem;
      right: 1.5rem;
      z-index: 9999;
      display: flex;
      flex-direction: column;
      gap: 0.75rem;
      align-items: flex-end; 
    }
    .toast {
      padding: 1rem 1.5rem;
      border-radius: var(--radius-md);
      box-shadow: var(--shadow-md);
      font-weight: 500; 
      border: 1px solid;
      opacity: 0;
      animation: toast-in 0.3s ease-out forwards, toast-out 0.5s ease-in 3.5s forwards; 
    }
    .toast-success { background-color: var(--toast-success-bg); color: var(--toast-success-text); border-color: var(--toast-success-border); 
    }
    .toast-error   { background-color: var(--toast-error-bg); color: var(--toast-error-text); border-color: var(--toast-error-border);
    }
    /* ★[N2] warning種別(黄系): 「失敗ではないが注意」=代替描画/続行 などを error(赤=失敗誤認)と区別する */
    .toast-warning { background-color: var(--toast-warning-bg); color: var(--toast-warning-text); border-color: var(--toast-warning-border);
    }
    /* ★[新規J] 管理者メニュー項目のマウスオーバーで色変化(#adminMenuはbody直下へ移動するため親非依存セレクタ。インラインbackground:noneに勝つよう!important) */
    .admin-menu-item { transition: background-color 0.12s ease; }
    .admin-menu-item:hover, .admin-menu-item:focus { background-color: #f3f4f6 !important; }

    @keyframes toast-in {
      from { transform: translateX(100%); 
      opacity: 0; }
      to { transform: translateX(0); opacity: 1; 
      }
    }
    @keyframes toast-out {
      from { opacity: 1; 
      }
      to { opacity: 0; 
      }
    }

    /* --- Modal Styles --- */
    .modal-overlay {
      position: fixed; 
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.6);
      display: flex;
      justify-content: center;
      align-items: center; 
      z-index: 1000;
    }
    .modal-content {
      width: 90%;
      max-width: 90vh;
      max-height: 90vh; 
      overflow-y: auto;
    }
    .drop-zone {
      border: 2px dashed var(--border-color);
      border-radius: var(--radius-md); 
      padding: 2rem;
      text-align: center;
      color: var(--text-light);
      cursor: pointer;
      transition: background-color 0.2s, border-color 0.2s;
      position: relative; 
    }
    .drop-zone.drag-over {
      background-color: #EFF6FF;
      border-color: var(--primary-color); 
    }
    .image-preview {
      max-width: 100%;
      max-height: 200px;
      margin: 0 auto 1rem; 
      display: block;
    }

    @keyframes waveMotion {
      0% {
        /* 波を左端の外側からスタート */
        background-position: -200px 0; 
      }
      100% {
        /* 波を右端の外側へ通過させる */
        background-position: calc(100% + 200px) 0; 
      }
    }

    /* 「生成ボタン」が無効の時だけアニメーションを適用 */
    button#generateBtn:disabled,
    button#updatePageBtn:disabled { /* ◀ [追加] updatePageBtn も対象にする */
      justify-content: center !important; /* ◀ [追加] タイマー表示のために中央寄せを強制 */
      /* 既存の背景色の上に、半透明の「波」を重ねる */
      background-image: linear-gradient(
        90deg, /* 左から右へ */
        transparent 0%,
        rgba(255, 255, 255, 0.3) 50%, /* 波の「明るい」部分 */
        transparent 100%
      ); 
      background-size: 200px 100%; /* 波の幅を200pxに */
      background-repeat: no-repeat; 
      /* 1.5秒かけて、無限に、直線的にアニメーション（waveMotionを適用） */
      animation: waveMotion 1.5s infinite linear;
    }

    /* ★[要望4] 排他制御中の「相手」ボタン(=実行中でない側): 処理中で押せないことを示すグレーアウト。
       実行中の本人ボタンには付かない(波アニメ+タイマー表示のまま)。:disabled の波アニメより高い詳細度で上書きする。 */
    button.btn-locked,
    button#generateBtn.btn-locked:disabled,
    button#updatePageBtn.btn-locked:disabled {
      background-image: none !important;
      animation: none !important;
      opacity: 0.5 !important;
      filter: grayscale(0.7);
      cursor: not-allowed !important;
    }

    @media (max-width: 768px) {
      .container { padding: 1.5rem 1rem; 
      }
      .card-body, .card-header { padding: 1.25rem; 
      }
      /* .form-grid { grid-template-columns: 1fr; } */ /* ユーザーの要望によりコメントアウト */
    }

    @media (max-width: 576px) {
      .toast-container {
        left: 1rem; 
        right: 1rem;
        bottom: 1rem;
        align-items: stretch;
      }
    }

    #splitter {
      transition: background-color 0.2s ease; 
    }
    #splitter:hover, #splitter.dragging {
      background: var(--secondary-color) !important; 
    }

/* --- Responsive Design (ResizeObserver) --- */

    /* ▼▼▼ [新規追加] JS制御による縦積み(1カラム)レイアウト ▼▼▼ */
    #left-pane.vertical-layout .main-grid {
      grid-template-columns: 1fr !important;
    }

    /* #left-pane に .narrow-layout クラスが付与された際のスタイル */

    /* メイングリッドを1カラムに (強制) */
    #left-pane.narrow-layout .main-grid {
      grid-template-columns: 1fr !important; 
    }
    
    /* フォームグリッドも1カラムに (強制) */
    /* #left-pane.narrow-layout .form-grid {
      grid-template-columns: 1fr !important; 
    } */

    /* ▼▼▼ [追加] 横スクロール解消のための強制縦積み ▼▼▼ */

    /* プリセット選択欄を縦積みに */
    /* #left-pane.narrow-layout .preset-control-row {
      flex-direction: column !important; 
      align-items: stretch !important; 
    } */

    /* カラーピッカーとテキスト入力欄を縦積みに */
    /* #left-pane.narrow-layout .input-group {
      flex-direction: column !important; 
      align-items: stretch !important; 
      gap: 0.5rem !important; 
    } */

    /* カラーピッカーのサイズ調整 */
    /* #left-pane.narrow-layout .input-group input[type="color"] {
       width: 100% !important; 
    } */

    /* 「開く」ボタンなどの幅を調整 */
    /* #left-pane.narrow-layout .input-group .btn-secondary {
       width: 100% !important; 
       justify-content: center; 
    } */
    /* ▲▲▲ [追加] ここまで ▲▲▲ */
    
    /* ▼▼▼ 【ここから追加】最小幅の時にカードの枠を消し、全余白を縮小 ▼▼▼ */

    /* 850px未満で適用 */
    #left-pane.narrow-layout .card {
      background: transparent; /* カードの背景を透明に */
      box-shadow: none;        /* 影を消す */
      border: none;            /* 枠線を消す */
    }
    
    #left-pane.narrow-layout .card-header {
      display: none !important; /* 「1. ...」「2. ...」「オプション設定」のヘッダーを非表示に */
    }
    
    /* --- カード間の隙間 --- */
    #left-pane.narrow-layout .left-column {
      gap: 0.5rem; /* カード間の隙間を詰める (元は 2rem) */
    }

    /* --- カード内の基本余白 (左右0, 上下 0.75rem) --- */
    #left-pane.narrow-layout .card-body {
      padding: 0.75rem 0; 
    }

    /* --- 1. 「Geminiを開く」ボタンの上下余白 --- */
    /* .card-headerを持つカードの直後の.card-body (主にGeminiボタンの箇所) */
    #left-pane.narrow-layout .card-header + .card-body {
      padding-top: 0.25rem;
      padding-bottom: 0.25rem;
    }

    /* --- 2. 「プレゼンテーションを生成」ボタンの下の余白 --- */
    /* .editor-section (2. ...入力) の .card-body */
    #left-pane.narrow-layout .editor-section .card-body {
       padding-top: 0.5rem;
       padding-bottom: 0rem; /* ボタン下の余白を詰める */
    }
    
    #left-pane.narrow-layout .json-validation {
       margin-bottom: 0.75rem; /* (1rem -> 0.75rem) */
       padding: 0.75rem;       /* (1rem -> 0.75rem) */
    }

    /* --- 3. 「オプション設定」エリアの全余白 --- */
    #left-pane.narrow-layout .settings-section .card-body {
      padding-top: 0.25rem; /* オプション設定エリアの開始余白 */
      padding-bottom: 0;
    }

    #left-pane.narrow-layout .preset-management {
      margin-bottom: 0.75rem; /* (1.5rem -> 0.75rem) */
      padding: 0.75rem;       /* (1rem -> 0.75rem) */
    }

    #left-pane.narrow-layout .form-grid {
      gap: 0.75rem; /* (1.25rem -> 0.75rem) */
    }

    #left-pane.narrow-layout .collapsible-section {
      margin: 0.75rem 0; /* (1.25rem -> 0.75rem) */
    }
    
    #left-pane.narrow-layout .collapsible-header {
      padding: 0.5rem 0.75rem; /* 少し詰める */
    }

    #left-pane.narrow-layout .collapsible-content {
      padding: 0.75rem; /* (1.25rem -> 0.75rem) */
    }
    
    #left-pane.narrow-layout .note {
      padding: 0.5rem 0.75rem; /* (0.75rem 1rem -> 0.5rem 0.75rem) */
      margin-bottom: 0.75rem; /* (1rem -> 0.75rem) */
    }

    #left-pane.narrow-layout .button-group {
      margin-top: 1rem; /* (1.5rem -> 1rem) */
    }

    /* --- 狭い幅の時、プリセットと検証欄の枠も消す --- */
    #left-pane.narrow-layout .preset-management {
      background: transparent;
      border: none;
      padding: 0 0 0.75rem 0; /* 上の余白を消し、下の余白だけ残す */
    }
    
    #left-pane.narrow-layout .json-validation {
      background: transparent;
      border: none;
      padding: 0 0 0.75rem 0; /* 上の余白を消し、下の余白だけ残す */
    }
    
    /* --- 狭い幅の時、構成データ編集のラベルを上部に配置 --- */
    #left-pane.narrow-layout #editorFormArea .form-group-grid {
      flex-direction: column !important; /* ラベルと入力欄を縦積みに */
      align-items: stretch !important; /* 幅をコンテナに合わせる */
      gap: 0.25rem !important; /* ラベルと入力欄の隙間を詰める */
      padding: 0.5rem 0 !important; /* 項目間のパディング調整 */
    }

    #left-pane.narrow-layout #editorFormArea .editor-form-label {
      width: auto !important; /* 幅の固定を解除  */
      text-align: left !important; /* 左揃えに変更  */
      margin-top: 0 !important; /* 上部のマージンをリセット  */
      padding-right: 0 !important; /* 右のパディングをリセット  */
      white-space: normal !important; /* ラベルが折り返せるように  */
      margin-bottom: 0.25rem; /* 入力欄との間に少しマージンを追加 */
    }
    
    #left-pane.narrow-layout #editorFormArea .field-wrapper {
      gap: 0.75rem !important; /* カラーピッカーとテキスト入力欄の隙間調整 */
    }

    /* ▼▼▼ 【ここから追加】狭い幅の時、「のスライド」を非表示 ▼▼▼ */
    .slide-suffix {
      display: inline; /* 通常は表示 */
    }
    
    #left-pane.narrow-layout .validation-status .slide-suffix {
      display: none; /* narrow-layoutの時は非表示 */
    }

    /* 通常時: 省略版を非表示にし、通常版を表示 */
    .nav-text-short {
      display: none;
    }
    .nav-text-full {
      display: inline;
    }

    /* 狭い幅 (.narrow-layout) の時: 通常版を非表示にし、省略版を表示 */
    #left-pane.narrow-layout .nav-text-short {
      display: inline;
    }
    #left-pane.narrow-layout .nav-text-full {
      display: none;
    }

    /* --- 狭い幅の時、オプション設定の開閉を無効化し常時表示 --- */
    
    /* 「表示設定」「ロゴ設定」「背景設定」の各セクションの上マージンを調整 */
    #left-pane.narrow-layout .settings-section .collapsible-section {
      margin: 1.5rem 0 0.75rem 0; 
    }
    

    /* ▼▼▼ [無効化] 狭幅時のアコーディオン強制展開を停止 ▼▼▼ */
    /*
    #left-pane.narrow-layout .settings-section .collapsible-header {
      cursor: default;
      pointer-events: none;
      
      background: transparent;
      border: none;
      border-radius: 0;
      padding: 0 0 0.75rem 0; 

      border-bottom: 1px solid var(--border-color);
    }
    
    #left-pane.narrow-layout .settings-section .collapsible-header:hover,
    #left-pane.narrow-layout .settings-section .collapsible-header.active {
      background: transparent;
      color: var(--text-secondary);
    }
    
    #left-pane.narrow-layout .settings-section .collapsible-icon {
      display: none; 
    }

    #left-pane.narrow-layout .settings-section .collapsible-content {
      display: block !important; 
      background: transparent;
      border: none;
      border-radius: 0;
      padding: 0.75rem 0 0 0; 
    }
    */ 
    /* ▲▲▲ [無効化] ここまで ▲▲▲ */

    textarea#slideDataInput.height-adjusted {
      visibility: visible;
    }

    /* --- 狭い幅の時、コンテナ自体の左右余白を削除 (最優先) --- */
    #left-pane.narrow-layout .container {
      padding-left: 0.75rem !important;  /* ← !important を追加 */
      padding-right: 0.75rem !important; /* ← !important を追加 */
      
      /* 狭い時は上下の余白も詰める */
      padding-top: 0.75rem !important;
      padding-bottom: 0.75rem !important;
    }

    /* ▼▼▼ 【ここから修正】パネルクローズボタン (デザイン変更版) ▼▼▼ */
    .panel-close-btn {
      position: absolute;
      top: 0.25rem; /* ★ ギリギリに (0.5rem -> 0.25rem) */
      right: 0.25rem; /* ★ ギリギリに (0.5rem -> 0.25rem) */
      width: 24px; /* ★ 小さく (32px -> 24px) */
      height: 24px; /* ★ 小さく (32px -> 24px) */
      
      background-color: transparent;
      border: none;
      
      /* ★ ホバー時だけでなく常に丸くしておく（ホバー時のレイアウトずれ防止） */
      border-radius: 50%; 
      
      color: var(--text-light); /* 文字色を薄めに */
      font-size: 1.2rem; /* ★ 小さく (1.6rem -> 1.2rem) */
      font-weight: 400; /* × の太さ */
      line-height: 22px; /* ★ 小さく (30px -> 22px) */
      text-align: center;
      cursor: pointer;
      z-index: 20;
      transition: all 0.2s ease;
      user-select: none;
      display: none;
    }
    
    /* 2. ホバー時: 薄透明のグレーで丸い背景 */
    .panel-close-btn:hover {
      background-color: rgba(107, 114, 128, 0.1); /* 薄透明のグレー */
      /* ★ border-radius: 50%; は削除 (上で設定済み) */
      color: var(--text-secondary); /* 文字色を少し濃く */
    }
    /* ▲▲▲ 【修正ここまで】 ▲▲▲ */

    /* ▼▼▼ [新規追加] プレビュー版ヘッダーとレスポンシブ対応 ▼▼▼ */
    .preview-header {
      /* 既存の --primary-color-dark を使用 */
      background-color: var(--primary-color-dark); 
      color: var(--text-on-primary, #FFFFFF);
      padding: 0.25rem 3rem 0.25rem 1rem; /* 右は閉じるボタン分 */
      font-size: 0.875rem;
      font-weight: 500;
      text-align: center;
      position: relative;
      transition: background-color 0.2s ease;
      flex-shrink: 0; /* レイアウトが縮まないように */
      user-select: none;
    }
    .preview-header:hover {
      background-color: var(--primary-color);
    }

    .preview-header-close {
      position: absolute;
      top: 50%;
      right: 0.5rem;
      transform: translateY(-50%);
      background: none;
      border: none;
      color: var(--text-on-primary, #FFFFFF);
      font-size: 1.5rem;
      font-weight: 400;
      line-height: 1;
      padding: 0.25rem 0.5rem;
      cursor: pointer;
      opacity: 0.7;
      transition: opacity 0.2s ease;
    }
    .preview-header-close:hover {
      opacity: 1;
    }

    #left-pane.narrow-layout {
      overflow-x: hidden !important; /* 横スクロールは完全に禁止 */
      overflow-y: scroll !important; /* 縦スクロールは常に許可 */

      /* Firefox用のスクロールバー非表示 */
      scrollbar-width: none; 
    }

    /* Webkit (Chrome, Safari, Edge) 用のスクロールバー非表示 */
    #left-pane.narrow-layout::-webkit-scrollbar {
      display: none; /* スクロールバーのUIを非表示にする */
    }

    #toggleFullscreenBtn,
    #showPreviewBtnLeft {
      /* 形状 */
      width: 30px;
      height: 30px;
      border-radius: 50%; /* ◀ 丸型に変更 */
      
      /* デザイン */
      border: none; /* ◀ 枠線なしに変更 */
      background-color: rgba(var(--primary-color-rgb, 66, 133, 244), 0.8); /* ◀ 半透明背景 */
      color: var(--text-on-primary, #FFFFFF); /* ◀ 文字色を白に */
      box-shadow: var(--shadow-md); /* ◀ 影 */
      backdrop-filter: blur(4px); /* ◀ すりガラス効果 */
      -webkit-backdrop-filter: blur(4px);

      /* アイコン配置 */
      font-size: 1.2em;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      
      /* アニメーション */
      transition: background-color 0.2s ease, color 0.2s ease;
    }
    
    #toggleFullscreenBtn:hover,
    #showPreviewBtnLeft:hover {
      background-color: rgba(var(--primary-color-rgb, 66, 133, 244), 1.0) !important; /* ◀ 不透明に変更 */
      color: var(--text-on-primary) !important;
    }

    /* ▼▼▼ [変更] 狭幅レイアウト時のUI調整（プレビュー閉じるボタン非表示を追加） ▼▼▼ */
    
    /* 1. 設定パネル(右フレーム)の「×」ボタンを非表示にする */
    #left-pane.narrow-layout #closeSettingsBtn {
      display: none !important;
    }

    /* 1b. [NEW] 「プレビューを閉じる」ボタンも非表示にする */
    #left-pane.narrow-layout #showLastSlideBtn {
      display: none !important;
    }

    /* 2. ボタンを横並びのまま均等幅にする */
    #left-pane.narrow-layout #buttonGroup {
      display: flex !important;
      flex-direction: row !important; /* 横並びを強制 */
      gap: 0.5rem !important; /* 隙間を詰める */
    }
    
    /* 3. 狭幅レイアウト時のボタンの幅を自動調整 */
    #left-pane.narrow-layout #buttonGroup .btn {
      width: auto !important; /* 幅を自動に */
      flex-grow: 1; /* 空きスペースを均等に埋める */
      padding-left: 0.5rem; /* パディングを少し詰める */
      padding-right: 0.5rem;
    }

    /* 4. JSONテキストエリアの縦スクロールバーを非表示 (ホイールスクロールは許可) */
    #left-pane.narrow-layout textarea#slideDataInput {
      overflow-y: scroll !important; /* スクロール機能は維持 */
      scrollbar-width: none; /* Firefox */
    }
    #left-pane.narrow-layout textarea#slideDataInput::-webkit-scrollbar {
      display: none; /* Chrome, Safari, Edge */
    }
    /* ▲▲▲ [変更] ここまで ▲▲▲ */


    /* --- [ここから新規追加] アクティベーションモーダルのドロップダウン用スタイル --- */
.purchase-select-wrapper {
  position: relative;
  flex-grow: 0;
  width: auto; 
}
.purchase-select {
  /* <select> を btn-secondary (source: 3014) 風の見た目にする */
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  text-align-last: center;
  border: none; 
  cursor: pointer; 
  transition: all 0.2s ease;
  font-weight: 600;
  letter-spacing: 0.025em;
  text-transform: none; 
  padding: 0.5rem 0.5rem;
  font-size: 0.8rem;
  background-color: var(--secondary-color);
  color: var(--text-on-primary); 
  border-radius: var(--radius-sm);
  white-space: nowrap;
}
.purchase-select:hover:not(:disabled) { 
  background-color: var(--secondary-color-dark);
}
/* ▼ ドロップダウン用の矢印(▼)を擬似要素で追加 */
.purchase-select-wrapper::after {
  display: none; /* ◀ 変更： 矢印を非表示 */
}
/* --- [新規追加ここまで] --- */


/* ▼▼▼ [新規追加] SVGエディタ用スタイル ▼▼▼ */
#svgEditorCanvas svg {
  background-color: #ffffff;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  max-width: 100%;
  max-height: 100%;
  cursor: pointer;
}
/* 選択された要素をハイライト */
.svg-editor-selected {
  cursor: move;
}
/* ホバー効果 */
#svgEditorCanvas svg *:hover:not(svg) {
  outline: 1px solid rgba(66, 133, 244, 0.5);
}
/* ▲▲▲ [新規追加] ここまで ▲▲▲ */


/* ▼▼▼ [追加] 矩形選択用のラバーバンド ▼▼▼ */
#selectionRect {
  fill: rgba(66, 133, 244, 0.1);
  stroke: rgba(66, 133, 244, 0.5);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  pointer-events: none; /* クリックを透過 */
  display: none;
}


/* ▼ [追加] SVGエディタ内でのテキスト選択を禁止（ドラッグ選択の妨げ防止） ▼ */
#svgEditorCanvas {
  user-select: none;          /* 標準 */
  -webkit-user-select: none;  /* Safari/Chrome */
  -moz-user-select: none;     /* Firefox */
}

/* 履歴リストアイテムのスタイル */
.history-item {
  padding: 0.75rem 1rem;
  border-bottom: 1px solid var(--border-color);
  cursor: pointer;
  transition: background-color 0.2s;
  background: #fff;
}
.history-item:last-child {
  border-bottom: none;
}
.history-item:hover {
  background-color: #EFF6FF; /* 薄い青 */
}
.history-item-title {
  font-weight: 600;
  font-size: 0.9rem;
  color: var(--text-primary);
  margin-bottom: 0.25rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.history-item-meta {
  font-size: 0.75rem;
  color: var(--text-secondary);
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.history-tag {
  display: inline-block;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 0.7rem;
  background: #eee;
  color: #555;
  margin-right: 0.5rem;
}


/* ボタンコンテナ：常に横並び、折り返しなし */
    #buttonGroup {
      display: flex !important;
      flex-direction: row !important;   /* 横方向を強制 */
      flex-wrap: nowrap !important;     /* 折り返しを禁止 */
      gap: 0.5rem !important;           /* ボタン間の隙間 */
      width: 100%;                      /* 親要素の幅いっぱいに */
      align-items: center;
    }

    /* コンテナ内のボタン：幅を自動調整し、均等に伸ばす */
    #buttonGroup .btn {
      width: auto !important;           /* 100%幅指定を解除 */
      flex: 1 1 0;                      /* 均等な比率で伸縮させる */
      white-space: nowrap;              /* テキストの折り返しを禁止 */
      padding-left: 0.25rem !important; /* パディングを少し詰める */
      padding-right: 0.25rem !important;
      margin: 0 !important;
      min-width: 0;                     /* 縮小限界をコンテンツ幅に依存させない(はみ出し防止) */
      justify-content: center;          /* アイコンとテキストを中央寄せ */
    }

    /* テキスト部分：はみ出す場合に省略記号(...)を表示する場合の保険 */
    #buttonGroup .btn .btn-text {
      overflow: hidden;
      text-overflow: ellipsis;
    }








    /* =========================================
       ▼▼▼ majinTools 統合用CSS (追加) ▼▼▼
       ========================================= */

    /* 切替ボタンのスタイル */
    #appSwitcherBtn {
      position: fixed;
      bottom: 1.5rem;
      left: 1.5rem;
      z-index: 9999; /* 最前面 */
      
      display: inline-flex;
      align-items: center;
      justify-content: center;
      
      background-color: #333; /* デフォルトは黒系 */
      color: #fff;
      border: 1px solid rgba(255,255,255,0.2);
      border-radius: 30px;
      padding: 0.5rem 1.2rem;
      
      font-family: var(--font-family-base);
      font-size: 0.85rem;
      font-weight: 600;
      cursor: pointer;
      box-shadow: 0 4px 6px rgba(0,0,0,0.2);
      transition: all 0.3s ease;
    }
    
    #appSwitcherBtn:hover {
      transform: translateY(-2px);
      box-shadow: 0 6px 12px rgba(0,0,0,0.3);
    }

    /* majinToolsモードのボタン色 */
    #appSwitcherBtn.active-mode {
      background-color: var(--primary-color); /* 青 */
      border-color: transparent;
    }

    /* majinTools コンテナ (全画面オーバーレイ) */
    #majinToolsContainer {
      display: none; /* 初期状態は非表示 */
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: #f8f9fa; /* 専用背景色 */
      z-index: 9000; /* アプリコンテンツの上、トースト/モーダルより下 */
      overflow-y: auto;
      padding: 40px 20px;
      
      /* 以下、majinTools固有のCSS変数 */
      --mt-primary: #1a73e8;
      --mt-text: #202124;
      --mt-border: #dadce0;
    }

    /* majinTools内部のスタイル (スコープ化) */
    #majinToolsContainer .mt-app-card {
      background: white;
      width: 100%;
      max-width: 580px;
      border-radius: 8px;
      box-shadow: 0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15);
      padding: 30px 40px;
      text-align: center;
      margin: 0 auto 24px auto;
    }
    #majinToolsContainer h1 { font-size: 24px; font-weight: 400; margin-bottom: 30px; color: var(--mt-text); margin-top: 0; }
    
    #majinToolsContainer .drop-zone {
      border: 2px dashed var(--mt-border);
      border-radius: 12px;
      padding: 40px 20px;
      background-color: #fff;
      cursor: default;
      transition: all 0.2s ease;
      position: relative;
      outline: none;
    }
    #majinToolsContainer .drop-zone:hover, 
    #majinToolsContainer .drop-zone.dragover {
      background-color: #f8fbff;
      border-color: var(--mt-primary);
    }
    #majinToolsContainer .drop-zone .icon { font-size: 48px; color: #5f6368; margin-bottom: 10px; display: block;}
    #majinToolsContainer .drop-zone p { margin: 0; color: #5f6368; font-size: 16px; }
    #majinToolsContainer .browse-btn {
      color: var(--mt-primary); font-weight: 500; cursor: pointer; padding: 4px 8px; border-radius: 4px;
    }
    #majinToolsContainer .browse-btn:hover { background-color: #e8f0fe; text-decoration: underline; }

    #majinToolsContainer .file-display {
      display: none; align-items: center; background: #e8f0fe; color: var(--mt-primary);
      padding: 10px 15px; border-radius: 18px; font-weight: 500; margin-top: 20px;
      font-size: 14px; justify-content: center; gap: 8px;
    }

    #majinToolsContainer .options-area { margin-top: 30px; display: flex; justify-content: center; color: var(--mt-text); }
    #majinToolsContainer .toggle-switch { display: flex; align-items: center; cursor: pointer; gap: 12px; }
    #majinToolsContainer .toggle-label { font-size: 14px; }
    #majinToolsContainer .switch { position: relative; display: inline-block; width: 36px; height: 20px; }
    #majinToolsContainer .switch input { opacity: 0; width: 0; height: 0; }
    #majinToolsContainer .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #bdc1c6; transition: .4s; border-radius: 34px; }
    #majinToolsContainer .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
    #majinToolsContainer input:checked + .slider { background-color: var(--mt-primary); }
    #majinToolsContainer input:checked + .slider:before { transform: translateX(16px); }

    #majinToolsContainer .mt-action-btn {
      background-color: var(--mt-primary); color: white; border: none; border-radius: 4px;
      padding: 10px 24px; font-size: 14px; font-weight: 500; margin-top: 30px;
      cursor: pointer; width: 100%; height: 40px; transition: box-shadow 0.2s;
      font-feature-settings: "tnum"; font-variant-numeric: tabular-nums; background-image: none;
    }
    #majinToolsContainer .mt-action-btn:hover { background-color: #1765cc; box-shadow: 0 1px 2px rgba(60,64,67,0.3); }
    #majinToolsContainer .mt-action-btn:disabled { cursor: default; box-shadow: none; opacity: 1; }
    
    @keyframes mt-wave-seamless-ltr { 0% { background-position: 200% 50%; } 100% { background-position: 0% 50%; } }
    #majinToolsContainer .mt-action-btn.processing {
      background-image: linear-gradient(90deg, #1a73e8 0%, #6ab7ff 50%, #1a73e8 100%);
      background-size: 200% 100%; animation: mt-wave-seamless-ltr 1.5s linear infinite;
      cursor: wait; background-color: transparent;
    }
    #majinToolsContainer .mt-action-btn.completed { background-image: none; background-color: #1a73e8; cursor: pointer; }

    #majinToolsContainer .status-text { margin-top: 15px; font-size: 13px; color: #5f6368; min-height: 20px; }
    #majinToolsContainer .success-msg { color: #137333 !important; font-weight: 500; }
    #majinToolsContainer .error-msg { color: #d93025 !important; }

    #majinToolsContainer .history-section { width: 100%; max-width: 580px; margin: 10px auto; }
    #majinToolsContainer .history-header { font-size: 14px; color: #5f6368; margin-bottom: 10px; font-weight: 500; padding-left: 5px; }
    #majinToolsContainer .history-item { display: flex; justify-content: space-between; align-items: center; background: white; padding: 12px 20px; margin-bottom: 8px; border-radius: 8px; transition: background-color 0.2s; text-decoration: none; color: #5f6368; box-shadow: 0 1px 2px 0 rgba(60,64,67,0.1); }
    #majinToolsContainer .history-item:hover { background-color: white; border-color: #dadce0; box-shadow: 0 1px 3px 1px rgba(60,64,67,0.15); }
    #majinToolsContainer .history-name { font-size: 14px; color: var(--mt-primary); font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 70%; display: flex; align-items: center; gap: 8px; }
    #majinToolsContainer .history-time { font-size: 12px; color: #5f6368; }




    /* ▼▼▼ 画像分解ツール用CSS ▼▼▼ */
#ist_canvasContainer .ist-selected {
    outline: 2px dashed #4285F4;
    cursor: move;
}
#ist_canvasContainer .ist-layer-object:hover {
    outline: 1px solid rgba(66, 133, 244, 0.5);
}
#imageSplitterModal .toolbar-group {
    border-right: 1px solid #e5e7eb;
}

/* ★[新規H] Nanobanana Editor(imageSplitterモーダル)を黒テーマに戻す */
#imageSplitterModal .modal-content.card { background: #1a1e26 !important; }
#imageSplitterModal .card-header { background: #1f242e !important; border-bottom: 1px solid #39414f !important; }
#imageSplitterModal #ist_pageIndicator { color: #e5e7eb !important; }
#imageSplitterModal #ist_closeBtn { color: #cbd5e1 !important; }
#imageSplitterModal #ist_canvasContainer { background-color: #2a2f3a !important; }
#imageSplitterModal #ist_loadingOverlay { background: rgba(20,24,33,0.9) !important; }
#imageSplitterModal #ist_loadingText { color: #e5e7eb !important; }
#imageSplitterModal .ist-canvas-wrap { background-color: #2a2f3a !important; }
#imageSplitterModal #ist_scopeToggle { background: #2a2f3a !important; }
/* 上部アクションボタン(再解析/一括テキスト変換/一括画像変換/1枚の画像として反映)を暗テーマ配色に */
#imageSplitterModal .btn-secondary { background: #353c49 !important; color: #e8eaed !important; border: 1px solid #4a525f !important; }
#imageSplitterModal .btn-secondary:hover { background: #434b59 !important; }
/* ★[修正] ヘッダーのボタンレイアウト崩れ防止: 共通 .btn の width:100% を継承するとヘッダー内ボタンが
   フル幅で縦積みになるため、ヘッダー内の .btn は width:auto(内容幅)にして横並びに戻す。
   (undo/redoはインライン width:32px が優先されるため不変。前/次画像ボタンは内容幅になる) */
#imageSplitterModal .card-header .btn { width: auto; }

/* ▼▼▼ [追加] インラインテキスト編集用スタイル ▼▼▼ */
.inline-editor-textarea {
    width: 100%;
    height: 100%;
    border: none;
    padding: 0;
    margin: 0;
    background: rgba(255, 255, 255, 0.95);
    resize: none;
    outline: 2px solid #4285F4;
    overflow: hidden;
    font-family: sans-serif;
    line-height: 1.1;
    box-sizing: border-box;
    white-space: pre;
    text-align: center; /* 中央揃えをデフォルトに */
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

/* モーダル内の × ボタンのホバー効果 */
#ist_closeBtn:hover {
  color: var(--text-secondary) !important;
}


/* ▼▼▼ [追加] スクロールバー非表示用クラス ▼▼▼ */
.hide-scrollbar {
  -ms-overflow-style: none;  /* IE and Edge */
  scrollbar-width: none;  /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
  display: none;  /* Chrome, Safari, Opera */
}

</style>
</head>
<body style="display: flex; flex-direction: column; height: 100vh; overflow: hidden;">

  <div id="previewHeader" class="preview-header" style="display: none;">
    <span id="previewHeaderContent" style="cursor: pointer;">
      このライセンスは 一般提供クレジット表示版 です。クリックして利用条件をご確認ください。
    </span>
    <button id="closePreviewHeaderBtn" class="preview-header-close" title="閉じる">&times;</button>
  </div>

  <div id="app-layout" style="display: grid; grid-template-columns: 1fr; height: 100%; width: 100%; overflow: hidden;">
    
    <div id="slidePreviewContainer" style="display: none; height: 100%; background: var(--bg-surface); position: relative;"><div id="closePreviewBtn" class="panel-close-btn" title="設定画面を最大化">×</div>

    <div id="previewOverlay" style="
        display: none; 
        position: absolute; 
        top: 0; 
        left: 0; 
        width: 100%; 
        height: 100%; 
        background: rgba(200, 200, 200, 0.2); /* 待機中と分かるよう少し暗く */
        z-index: 20; /* 他のボタン(z-index: 10)より手前 */
        cursor: wait; /* カーソルを待機中に */
    "></div>
    
    <a id="openSlideNewTabBtn" 
         href="#" 
         target="_blank" 
         rel="noopener noreferrer" 
         style="
            display: none; /* JSで表示制御されます */
            position: absolute;
            top: 1rem; /* 上からの位置 */
            left: 50%; /* 中央寄せ */
            transform: translateX(-50%); /* 中央寄せ */
            z-index: 10; /* 手前に表示 */
            
            padding: 0.5rem 1.25rem; /* ボタンの余白 */
            font-size: 0.875rem;
            font-weight: 600;
            color: var(--text-on-primary, #FFFFFF); /* 文字色 */
            
            /* プライマリカラー連動の半透明背景 */
            background-color: rgba(var(--primary-color-rgb, 66, 133, 244), 0.8); 
            backdrop-filter: blur(4px); /* すりガラス効果 */
            -webkit-backdrop-filter: blur(4px);
            
            border: none;
            border-radius: 9999px; /* ピロー型（角丸） */
            box-shadow: var(--shadow-md); /* 影 */
            
            text-decoration: none;
            transition: all 0.2s ease;
            white-space: nowrap;
         "
         onmouseover="this.style.backgroundColor='rgba(var(--primary-color-rgb, 66, 133, 244), 1.0)'"
         onmouseout="this.style.backgroundColor='rgba(var(--primary-color-rgb, 66, 133, 244), 0.8)'"
      >
        スライドを別タブで表示
      </a>

    <iframe id="slidePreviewFrame" src="about:blank" style="width: 100%; height: 100%; border: none;"></iframe>
    <div id="fullscreenToggleContainer" style="position: absolute; bottom: 1.5rem; right: 1.5rem; z-index: 10; display: none;">

      <button id="toggleFullscreenBtn" class="primary-color" title="全画面プレビューの切り替え">
        <span id="fullscreenIcon">></span>
      </button>

    </div>
    </div>

    <div id="splitter" style="display: none; width: 5px; cursor: col-resize; background: #F3F4F6;"></div>

    <div id="left-pane" style="display: flex; flex-direction: column; height: 100%;
overflow-y: auto; position: relative;"><div id="closeSettingsBtn" class="panel-close-btn" title="プレビューを最大化">×</div>
      
      <div class="container" style="padding: 1.5rem; max-width: 100%; flex-grow: 1;">
        
        <div class="main-grid">
      
          <div class="left-column">
            
            <div class="card">
              <!-- ★[新規C] card-headerをflexコンテナ化(既存パターン7033と同様)。背景が全幅に伸び「右側が真っ白」を解消し、⋮はフロー内のflexアイテムにしてnarrow-layout崩れを防止 -->
              <div class="card-header" style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
                <h2 style="margin: 0; flex: 1; font-size: inherit; font-weight: inherit; color: inherit;">Geminiでスライド構成データの作成</h2>
                <div id="adminMenuWrap" style="position: relative; display: none; flex-shrink: 0;">
                  <button id="adminMenuButton" type="button" title="管理者メニュー" aria-haspopup="true" aria-expanded="false"
                          style="background: none; border: none; cursor: pointer; padding: 0 0.5rem; font-size: 1.4rem; font-weight: 700; line-height: 1; color: var(--text-color-secondary, #6b7280); border-radius: 4px; align-self: center;">
                    &#8942;
                  </button>
                  <div id="adminMenu" hidden
                       style="position: absolute; right: 0; top: 100%; margin-top: 4px; min-width: 200px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,0.12); z-index: 1000; overflow: hidden;">
                    <button type="button" class="admin-menu-item" data-action="gem"
                            style="display: block; width: 100%; text-align: left; padding: 0.6rem 0.9rem; background: none; border: none; cursor: pointer; font-size: 0.875rem; color: #111827;">Gemの設定</button>
                    <button type="button" class="admin-menu-item" data-action="share"
                            style="display: block; width: 100%; text-align: left; padding: 0.6rem 0.9rem; background: none; border: none; cursor: pointer; font-size: 0.875rem; color: #111827; border-top: 1px solid #f3f4f6;">設定プリセットの共有</button>
                    <button type="button" class="admin-menu-item" data-action="admins"
                            style="display: block; width: 100%; text-align: left; padding: 0.6rem 0.9rem; background: none; border: none; cursor: pointer; font-size: 0.875rem; color: #111827; border-top: 1px solid #f3f4f6;">管理者の設定</button>
                  </div>
                </div>
              </div>
              <div class="card-body">
                <a id="geminiGemButton" href="#" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
                  Geminiを開く
                </a>
                </div>
            </div>
            <div class="card editor-section">
              <h2 class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
  <span>スライド構成データの入力</span>
  <span style="display: flex; align-items: center; gap: 0.6rem;">
    <span id="uploadFileBtn" title="JSON または PDF をアップロード" style="cursor: pointer; font-size: 1.2rem; opacity: 0.7; transition: opacity 0.2s;">
      📤
    </span>
    <span id="historyRestoreBtn" title="スライドIDから設定とデータを読み込み" style="cursor: pointer; font-size: 1.2rem; opacity: 0.7; transition: opacity 0.2s;">
      🕒
    </span>
  </span>
</h2>
              <div class="card-body">
                <input type="file" id="uploadFileInput" accept=".json,application/json,application/pdf" style="display:none;">
                <div id="jsonValidation" class="json-validation" style="display: none;">
                  <div class="validation-status" id="validationStatus">
                    <span class="validation-icon">⏳</span>
                    <span class="validation-text">スライド構成データの貼付け待機中...</span>
                  </div>
                  <div class="validation-details" id="validationDetails"></div>
                </div>
                <textarea id="slideDataInput" placeholder='ここにGeminiで生成したスライド構成データを貼付け&#13;&#10;（ダブルクリックで貼付けも可能。PDFはドロップでアップロードも可能。）'></textarea>

                <div class="button-group" id="buttonGroup">
                    <button id="showLastSlideBtn" type="button" class="btn btn-primary" style="display: none;">
                      スライドを表示
                    </button>
                    <button id="updatePageBtn" type="button" class="btn btn-secondary" style="display: none;">
                      <span class="btn-text">このページを更新</span>
                      <span class="btn-timer" style="display: none;
margin-left: 0.5rem; font-weight: 400; font-size: 0.9em;"></span>
                    </button>
                    <button id="generateBtn" type="button" class="btn btn-primary" style="display: none;">
                      <span class="btn-text">プレゼンテーションを生成</span>
                      <span class="btn-timer" style="display: none;
margin-left: 0.5rem; font-weight: 400; font-size: 0.9em;"></span>
                    </button>
                </div>
              </div>
            </div>

          </div> <div style="display: flex; flex-direction: column;">
            
            <div class="card settings-section">
              <h2 class="card-header">オプション設定</h2>
              <div class="card-body">
                
                <div class="preset-management">
                  <div class="preset-controls">
                    <label for="presetSelect">設定プリセット</label>
                    <div class="preset-control-row">
                      <select id="presetSelect" class="preset-select">
                        <option value="">プリセットを選択...</option>
                      </select>
                      <button id="savePreset" type="button" class="btn-preset">保存</button>
                      <div class="preset-menu-wrapper">
                        <button id="presetMenuButton" type="button" class="btn-secondary preset-menu-button" aria-haspopup="true" aria-expanded="false" title="プリセットメニュー">⋯</button>
                        <div id="presetActionMenu" class="preset-action-menu" role="menu" hidden>
                          <button id="exportPreset" type="button" class="preset-menu-item" role="menuitem" disabled>エクスポート</button>
                          <button id="exportAllPresets" type="button" class="preset-menu-item" role="menuitem">エクスポート（全件）</button>
                          <button id="importPresets" type="button" class="preset-menu-item" role="menuitem">インポート</button>
                          <button id="deletePreset" type="button" class="preset-menu-item" role="menuitem" disabled>削除</button>
                        </div>
                        <input id="presetImportFile" type="file" accept="application/json,.json" style="display: none;">
                      </div>
                    </div>
                  </div>
                </div>

                <div class="form-grid">
                    <div class="form-group">
                        <label for="primaryColor">プライマリカラー</label>
                        <div class="input-group">
                            <input type="color" id="primaryColor" value="#4285F4">
                            <input type="text" id="primaryColorText" class="form-control" value="#4285F4">
                        </div>
                    </div>
                    <div class="form-group">
                      <label for="graphColorTheme">グラフカラー</label>
                      <select id="graphColorTheme" class="form-control">
                        <option value="original">オリジナル</option>
                        <option value="primary" selected>プライマリ</option>
                        <option value="gemini">Gemini</option>
                        <!--<option value="night">Night</option>-->
                      </select>
                    </div>
                    <div class="form-group">
                        <label for="footerText">フッターテキスト</label>
                        <input type="text" id="footerText" class="form-control" value="© Google Inc.">
                    </div>
                    <div class="form-group" style="display: flex; flex-direction: column; justify-content: flex-end;">
                        <div class="checkbox-wrapper" style="padding-top: 0; padding-bottom: 0; height: 44px; align-items: center;">
                            <input type="checkbox" id="showPageNumber" checked>
                            <label for="showPageNumber">ページ番号を表示</label>
                        </div>
                    </div>
                    <div class="form-group grid-col-full">
                        <label for="driveFolderUrl">保存先ドライブフォルダURL</label>
                        <div class="input-group">
                            <input type="text" id="driveFolderUrl" class="form-control" value="https://drive.google.com/drive/my-drive">
                            <button id="openFolderBtn" type="button" class="btn-secondary">開く</button>
                        </div>
                    </div>
                </div>
                
                <div class="collapsible-section">
                  <div class="collapsible-header">
                    <span>表示設定</span>
                    <span class="collapsible-icon">▼</span>
                  </div>
                  <div class="collapsible-content">
                      <label for="fontFamily">フォント</label>
                      <select id="fontFamily" class="form-control">
                        <option value="Noto Sans JP" selected>Noto Sans JP</option>
                        <option value="Arial">Arial</option>
                        <option value="M PLUS 1p">M PLUS 1p</option>
                        <option value="Noto Serif JP">Noto Serif JP</option>
                      </select>
                      <div class="form-grid" style="margin-top: 1rem;">
                          <div class="form-group">
                            <label for="largeFontColor">大フォント</label> 
                            <div class="input-group">
                              <input type="color" id="largeFontColor" value="#333333">
                              <input type="text" id="largeFontColorText" class="form-control" value="#333333">
                            </div>
                          </div>
                          <div class="form-group">
                            <label for="smallFontColor">小フォント</label>
                            <div class="input-group">
                              <input type="color" id="smallFontColor" value="#1F2937">
                              <input type="text" id="smallFontColorText" class="form-control" value="#1F2937">
                            </div>
                          </div>
                        </div>
                      <div class="checkbox-wrapper" style="border-top: 1px solid var(--border-color); margin-top: 1rem; padding-top: 1rem;">
                        <input type="checkbox" id="showTitleUnderline" checked>
                        <label for="showTitleUnderline">タイトル下にアンダーラインを表示</label>
                      </div>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="showBottomBar" checked>
                        <label for="showBottomBar">スライド下部にフッターバーを表示</label>
                      </div>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="showDateColumn" checked>
                        <label for="showDateColumn">タイトルスライドに日付を表示</label>
                      </div>
                      <div class="checkbox-wrapper" style="border-top: 1px solid var(--border-color); margin-top: 1rem; padding-top: 1rem;">
                        <input type="checkbox" id="enableGradient">
                        <label for="enableGradient">グラデーションを適用</label>
                      </div>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="enableGraphAnimation">
                        <label for="enableGraphAnimation">アニメーショングラフ</label>
                        <!-- ★[新規L] アニメグラフ有効時のみ右に表示する変換形式(APNG/GIF) -->
                        <select id="graphAnimFormatSelect" class="form-control" style="display:none; margin-left:auto; width:auto; flex:0 0 auto;">
                          <option value="apng" selected>APNG（デフォルト）</option>
                          <option value="gif">GIFアニメ（Vids動画変換対応）</option>
                        </select>
                      </div>
                      <div id="gradient-options">
                        <div class="form-grid">
                          <div class="form-group">
                            <label for="gradientStart">開始色</label>
                            <div class="input-group">
                              <input type="color" id="gradientStart" value="#4285F4">
                              <input type="text" id="gradientStartText" class="form-control" value="#4285F4">
                            </div>
                          </div>
                          <div class="form-group">
                            <label for="gradientEnd">終了色</label>
                            <div class="input-group">
                              <input type="color" id="gradientEnd" value="#ff52df">
                              <input type="text" id="gradientEndText" class="form-control" value="#ff52df">
                            </div>
                          </div>
                        </div>
                        <div class="gradient-preview-section">
                          <label>グラデーションプレビュー</label>
                          <div id="gradientPreview" class="gradient-preview-box">
                            <span>グラデーション見本</span>
                          </div>
                        </div>
                      </div>
                      <div class="form-group grid-col-full" style="border-top: 1px solid var(--border-color); margin-top: 1rem; padding-top: 1rem;">
                        <!-- ★[新規E] 有効確認結果は項目名(ラベル)の右側に表示 -->
                        <label for="templateUrl" style="display:flex; align-items:center; flex-wrap:wrap; column-gap:0.4rem;">テンプレートスライドURL<span class="info-tip" tabindex="0" role="button" aria-label="説明">i<span class="info-tip-text">指定したGoogleスライドをテンプレートとしてスライドを生成します。テンプレートスライドは、4枚構成（1=タイトル用 / 2=セクション用 / 3=コンテンツ全般用 / 4=クロージング用）で作成してください。</span></span><span id="templateUrlStatus" style="margin-left:auto; font-size:0.8rem; font-weight:400; display:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:50%; text-align:right;"></span></label>
                        <div class="input-group">
                          <input type="text" id="templateUrl" class="form-control" value="" placeholder="https://docs.google.com/presentation/d/.../edit">
                        </div>
                      </div>
                      <!-- ★[新2] テンプレート余白は、テンプレートスライドURLに有効なURLが登録されている時だけ表示する -->
                      <div class="form-group" id="templateMarginGroup" style="margin-top: 0.75rem; display:none;">
                        <label style="display:flex; align-items:center;">テンプレート余白 (px)<span class="info-tip" tabindex="0" role="button" aria-label="説明">i<span class="info-tip-text">テンプレートの要素が生成される要素と重なってしまう場合に、上下左右の余白(px)を指定し重ならないように調整ができます。設定した余白の内側に要素が生成されるようになります。</span></span></label>
                        <div class="margin-compact-row">
                          <div class="margin-cell"><label for="marginTop">上</label><input type="number" id="marginTop" class="form-control" min="0" step="1" value="" placeholder="0"></div>
                          <div class="margin-cell"><label for="marginBottom">下</label><input type="number" id="marginBottom" class="form-control" min="0" step="1" value="" placeholder="0"></div>
                          <div class="margin-cell"><label for="marginLeft">左</label><input type="number" id="marginLeft" class="form-control" min="0" step="1" value="" placeholder="0"></div>
                          <div class="margin-cell"><label for="marginRight">右</label><input type="number" id="marginRight" class="form-control" min="0" step="1" value="" placeholder="0"></div>
                        </div>
                      </div>
                  </div>
                </div>

                <div class="collapsible-section">
                  <div class="collapsible-header">
                    <span>ロゴ設定</span>
                    <span class="collapsible-icon">▼</span>
                  </div>
                  <div class="collapsible-content">
                    <div class="note note-info">画像URLまたはGoogleドライブの共有リンクを入力してください。</div>
                    <div class="form-grid">
                      <div class="form-group">
                        <label for="headerLogoUrl">ヘッダーロゴ</label>
                        <div class="input-group">
                          <input type="text" id="headerLogoUrl" class="form-control" value="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Google_Gemini_logo.svg/1280px-Google_Gemini_logo.svg.png">
                          <button type="button" class="btn-secondary">開く</button>
                        </div>
                      </div>
                      <div class="form-group">
                        <label for="closingLogoUrl">クロージングロゴ</label>
                        <div class="input-group">
                          <input type="text" id="closingLogoUrl" class="form-control" value="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Google_Gemini_logo.svg/1280px-Google_Gemini_logo.svg.png">
                          <button type="button" class="btn-secondary">開く</button>
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
                
                <div class="collapsible-section">
                  <div class="collapsible-header">
                    <span>背景設定</span>
                    <span class="collapsible-icon">▼</span>
                  </div>
                  <div class="collapsible-content">
                    <div class="note note-info">画像URLまたはGoogleドライブの共有リンクを入力してください。</div>
                    <div class="form-grid">
                      <div class="form-group">
                        <label for="titleBgUrl">タイトル背景</label>
                        <div class="input-group">
                          <input type="text" id="titleBgUrl" class="form-control" value="">
                          <button type="button" class="btn-secondary">開く</button>
                        </div>
                      </div>
                      <div class="form-group">
                        <label for="sectionBgUrl">セクション背景</label>
                        <div class="input-group">
                          <input type="text" id="sectionBgUrl" class="form-control" value="">
                          <button type="button" class="btn-secondary">開く</button>
                        </div>
                      </div>
                      <div class="form-group">
                        <label for="mainBgUrl">メイン背景</label>
                        <div class="input-group">
                          <input type="text" id="mainBgUrl" class="form-control" value="">
                          <button type="button" class="btn-secondary">開く</button>
                        </div>
                      </div>
                      <div class="form-group">
                        <label for="closingBgUrl">クロージング背景</label>
                        <div class="input-group">
                          <input type="text" id="closingBgUrl" class="form-control" value="">
                          <button type="button" class="btn-secondary">開く</button>
                        </div>
                      </div>
                      <div class="form-group">
                        <label for="backgroundColor">背景カラー (全体)</label>
                        <div class="input-group">
                          <input type="color" id="backgroundColor" value="#FFFFFF">
                          <input type="text" id="backgroundColorText" class="form-control" value="#FFFFFF">
                        </div>
                      </div>
                    </div>
                  </div>
                </div>

                <div class="collapsible-section" id="devSection" <? if (!devMode) { ?>style="display:none;"<? } ?>>
                  <div class="collapsible-header">
                    <span>開発（開発者向け）</span>
                    <span class="collapsible-icon">▼</span>
                  </div>
                  <div class="collapsible-content">
                    <div class="note note-info">開発者向けの実験的な機能フラグと、生成パフォーマンスの計測です。URLに <code>?dev=1</code> を付けてアクセスしたときのみ表示されます。</div>
                    <div style="font-weight:600; margin:0.75rem 0 0.25rem;">実験的な機能フラグ</div>
                      <div class="checkbox-wrapper" style="border-top: 1px solid var(--border-color); margin-top: 1rem; padding-top: 1rem;">
                        <input type="checkbox" id="nativeSectionMode" checked>
                        <label for="nativeSectionMode">セクションのゴースト数字を編集可能な図形にする（既定ON）</label>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">ONにすると、セクションスライドの大きな番号(01/02…)を画像ではなく編集可能なネイティブテキストとして生成します（Slidesエディタで文字編集・色変更が可能）。テンプレート使用時は無効。変換処理のぶん生成が少し遅くなります。</p>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="nativeDecorMode" checked>
                        <label for="nativeDecorMode">下部バー等の装飾も編集可能な図形にする（既定ON・Phase1）</label>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">ONにすると、スライド下部のグラデーションバーを「画像/矩形ループ」ではなく編集可能なネイティブのグラデ図形として生成します（ゴースト数字も自動でネイティブ化）。デザインは変えず高速化・編集可能化を目指す実験機能。テンプレート使用時は無効。</p>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="restTextMode" checked>
                        <label for="restTextMode">テキストを一括生成して高速化する（既定ON・REST）</label>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">ONにすると、フッター・ページ番号・日付などのテキストを Slides API の一括更新でまとめて生成し、API往復を減らして高速化します（デザインは変えません）。実験機能。問題があればOFFで従来どおりに戻ります。</p>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="nativeChartMode" checked>
                        <label for="nativeChartMode">グラフ・SVGを編集可能な図形として配置する（実験的）</label>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">ONにすると、グラフ(チャートJSON)やSVGコードを画像(PNG)ではなく、分解された編集可能なネイティブ図形としてスライドに配置します（Slidesエディタで個々の要素を編集可能）。アニメーショングラフ・SVG内の埋め込み画像は従来どおり画像として配置されます。変換のぶん生成が少し遅くなる場合があります。実験機能・既定ON。</p>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="useNotebookLmSplit" checked>
                        <label for="useNotebookLmSplit">画像分解・テキスト抽出に Gemini Notebook エンジンを使う（実験的）</label>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">ONにすると、画像の自動分解（Nano Banana Editor）と文字認識(OCR)を、NotebookLM Image Editor 由来の新エンジンで実行します。既存ロジックはそのまま温存し、このスイッチでのみ切替わります（OFFや新エンジン未読込時は従来エンジンで動作）。今後のNotebookLM側の改良はモジュール差し替えで取り込めます。実験機能・既定ON。</p>
                      <div class="checkbox-wrapper">
                        <input type="checkbox" id="newVideoLogic" checked>
                        <label for="newVideoLogic">新しい動画処理ロジックを使う（既定ON）</label>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">ONにすると、動画から作るアニメーション(APNG)の生成を「フレームを少しずつ並列で圧縮し、すぐ破棄する」新方式で行います。メモリ使用を大きく抑えて大きい動画でも落ちにくくし、複数CPUコアで高速化します（画質は可逆＝無劣化）。実験機能・既定OFF。問題があればOFFで従来方式に戻ります。</p>
                      <div class="form-group" style="margin-top: 0.5rem;">
                        <label for="animationFormat">動画アニメの出力形式</label>
                        <select id="animationFormat">
                          <option value="apng">APNG（可逆・最高画質／自動で50MB以下に収める）</option>
                          <option value="gif">GIF（256色・小サイズ・確実に再生）</option>
                        </select>
                      </div>
                      <p style="font-size: 0.78rem; color: var(--text-secondary, #6b7280); margin: 0.25rem 0 0 1.6rem;">動画から作るアニメーションの形式です。<b>APNG</b>＝色もフレームも無劣化（可逆）。サイズが大きくなるため、解像度を最優先で自動的に下げて50MB以下に収めます（スライド表示では知覚しにくい）。<b>GIF</b>＝256色に減色するぶんファイルが小さく、Googleスライドでより確実にアニメ再生されます。実写の長いクリップで容量・安定性を重視するならGIFが無難です（「新しい動画処理ロジック」がONのときに適用）。</p>
                    <div style="font-weight:600; border-top:1px solid var(--border-color); margin:1rem 0 0.25rem; padding-top:1rem;">ゴースト数字の透明度（セクション背景の大きな番号）</div>
                    <div class="note note-info">セクションスライドの大きな背景番号(01/02…)の<strong>縦グラデーション</strong>の透明度です。上端と下端を 0〜1 で指定します（既定: 上 0.35 / 下 0.20）。ネイティブ図形（編集可能）・画像 いずれの経路にも反映されます。</div>
                    <div class="form-group" style="display:flex; gap:1rem;">
                      <div style="flex:1;">
                        <label for="ghostTopAlpha">上部の不透明度（0〜1）</label>
                        <input type="number" id="ghostTopAlpha" class="form-control" min="0" max="1" step="0.05" value="0.35">
                      </div>
                      <div style="flex:1;">
                        <label for="ghostBottomAlpha">下部の不透明度（0〜1）</label>
                        <input type="number" id="ghostBottomAlpha" class="form-control" min="0" max="1" step="0.05" value="0.2">
                      </div>
                    </div>
                    <div style="font-weight:600; border-top:1px solid var(--border-color); margin:1rem 0 0.25rem; padding-top:1rem;">計測（パフォーマンス）</div>
                    <div class="note note-info">直近に実行した「生成／更新」の<strong>段階別の所要時間</strong>です。先にスライドを1回<strong>生成（または更新）</strong>してから、下のボタンを押してください。どの処理が遅いかを特定して高速化に使います。</div>
                    <div class="form-group grid-col-full">
                      <button type="button" id="showPerfBtn" class="btn-secondary">⏱ 直近の生成の計測結果を表示</button>
                      <button type="button" id="copyPerfBtn" class="btn-secondary" style="margin-left:0.5rem;">📋 コピー</button>
                    </div>
                    <div class="form-group grid-col-full">
                      <textarea id="perfOutput" class="form-control" rows="12" readonly placeholder="ここに計測結果が表示されます（[PERF] ...）" style="font-family: monospace; white-space: pre; font-size: 0.8rem;"></textarea>
                    </div>
                  </div>
                </div>
                <script>
                  (function () {
                    function wirePerf() {
                      var btn = document.getElementById('showPerfBtn');
                      var copyBtn = document.getElementById('copyPerfBtn');
                      var out = document.getElementById('perfOutput');
                      if (!btn || !out) return;
                      btn.addEventListener('click', function () {
                        out.value = '取得中...';
                        google.script.run
                          .withSuccessHandler(function (res) { out.value = res || '(データなし)'; })
                          .withFailureHandler(function (err) { out.value = 'エラー: ' + (err && err.message ? err.message : err); })
                          .getLastPerfLog();
                      });
                      if (copyBtn) {
                        copyBtn.addEventListener('click', function () {
                          out.select();
                          try { document.execCommand('copy'); } catch (e) {}
                          if (navigator.clipboard) { try { navigator.clipboard.writeText(out.value); } catch (e) {} }
                        });
                      }
                    }
                    if (document.readyState === 'loading') {
                      document.addEventListener('DOMContentLoaded', wirePerf);
                    } else {
                      wirePerf();
                    }
                  })();
                </script>
                <script>
                  // ⓘ 情報ツールチップ: マウスオーバーはCSSで簡易表示、クリックするとモーダルで全文を表示
                  (function () {
                    function openInfoModal(html) {
                      var modal = document.getElementById('infoTipModal');
                      var body = document.getElementById('infoTipModalBody');
                      if (!modal) return;
                      if (body) body.innerHTML = html || '';
                      modal.style.display = 'flex';
                    }
                    function closeInfoModal() {
                      var modal = document.getElementById('infoTipModal');
                      if (modal) modal.style.display = 'none';
                    }
                    document.addEventListener('click', function (e) {
                      var modal = document.getElementById('infoTipModal');
                      // モーダル表示中: 背景 or 閉じるボタンで閉じる
                      if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
                        if (e.target === modal || (e.target.closest && e.target.closest('#infoTipModalClose'))) { closeInfoModal(); return; }
                      }
                      var tip = (e.target && e.target.closest) ? e.target.closest('.info-tip') : null;
                      if (tip && !(e.target.closest && e.target.closest('.info-tip-text'))) {
                        var txt = tip.querySelector('.info-tip-text');
                        openInfoModal(txt ? txt.innerHTML : '');
                        e.preventDefault(); e.stopPropagation();
                      }
                    });
                    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeInfoModal(); });
                  })();
                </script>
                </div>
            </div>

            <footer style="text-align: right; padding: 0.5rem 0.5rem 0.5rem 0.5rem; font-size: 0.8rem; color: #9CA3AF;">
              <a href="https://note.com/majin_108" target="_blank" style="color: inherit; text-decoration: none;">
              Developed by majin <?= appVersion ?></a>
              
            <div id="showPreviewBtnLeftContainer" style="
                position: fixed; /* ◀ [修正] absolute から fixed に変更 (スクロール追従) */
                bottom: 1rem; 
                left: 1.5rem; 
                z-index: 10; 
                display: none; /* JSで制御 */
            ">
              <button id="showPreviewBtnLeft" class="primary-color" title="プレビューを表示">
                <span id="showPreviewIcon">></span> </button>
            </div>
            </footer>
          
          </div> </div> </div> </div> 
    </div> <div id="toastContainer" class="toast-container"></div>
  
  <div id="imagePromptModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card">
      <h3 class="card-header">画像の指定</h3>
      <div class="card-body">
        <p id="imagePromptText" style="margin-bottom: 1rem;"></p>
        <div id="imageDropZone" class="drop-zone">
          <img id="imagePreview" src="#" alt="Image Preview" class="image-preview" style="display:none;">
          <span id="dropZoneText" style="cursor: default;">ここに画像をドラッグ＆ドロップ、<br> <a href="#" id="fileSelectLink" style="color: var(--primary-color); text-decoration: underline;">クリックしてファイルを選択</a><br>または画像をペーストしてください。
          </span>
        </div>
        <div class="button-group" style="display: flex; gap: 1rem; margin-top: 1.5rem;">
          <button id="confirmImageBtn" class="btn btn-primary" disabled>確定</button>
          <button id="cancelGenerationBtn" class="btn btn-secondary" style="background-color: var(--secondary-color);">画像なしで続行</button>
        </div>
      </div>
    </div>
  </div>
  
  <div id="gemUrlModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 600px; max-height: 90vh;">
      <h3 class="card-header">初回設定: GeminiでGemを作成してください</h3>
      <div class="card-body">
        
        <div id="gemHelpArea" style="display: block; padding-top: 0.25rem; font-size: 0.875rem; line-height: 1.6;"> 
          <p style="font-weight: 600; margin-top: 0;">① 以下のプロンプトをコピーする</p>

          <div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
            <textarea id="gemPromptTextarea" class="form-control" rows="5" readonly style="font-family: var(--font-family-code); font-size: 0.8125rem; background: #f9f9f9; resize: none;">読み込み中...</textarea>
            <button id="copyGemPromptBtn" type="button" class="btn-preset" style="flex-shrink: 0;">コピー</button>
          </div>

          <p style="font-weight: 600;">② Geminiを開く</p>
          <a href="https://gemini.google.com/gems/create" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: auto; padding: 0.5rem 1rem; text-transform: none; margin-bottom: 1rem;">
            Gem作成画面を開く
          </a>
          <p style="font-weight: 600;">③ Gemの名前を入力し、「カスタム指示」にコピーしたプロンプトを貼り付けて保存します。</p>
          <p style="font-weight: 600;">④ 保存が完了したら「チャットを開始」ボタンをクリックします。Gemのチャット画面が開いたら、ブラウザ上部のアドレスバーに表示されているURLをコピーします。</p>
          <p style="font-weight: 600;">⑤ コピーしたURLを下の入力欄に貼り付けて「保存して開始」をクリックします。</p>
        </div>

        <div class="form-group" style="margin-top: 1.5rem;"> 
          <label for="gemUrlInput">Gemini Gem URL</label>
          <input type="text" id="gemUrlInput" class="form-control" placeholder="https://gemini.google.com/gem/...">
          <small id="gemUrlError" style="color: var(--danger-color); display: none; margin-top: 0.5rem;"></small>
        </div>
        
        <div class="button-group" style="margin-top: 1.5rem; display: flex; flex-direction: column; gap: 0.75rem;">
          <button id="saveGemUrlBtn" class="btn btn-primary">保存して開始</button>
          <button id="cancelGemUrlBtn" type="button" class="btn-preset" style="display: none;">キャンセル</button>
        </div>

      </div>
    </div>
  </div>

  <!-- Slides API 有効化のおすすめ(起動時チェックで未有効のときだけ表示) -->
  <div id="slidesApiModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 580px; max-height: 90vh;">
      <h3 class="card-header">Google Slides API の追加をおすすめします（任意）</h3>
      <div class="card-body" style="font-size: 0.9rem; line-height: 1.75; word-break: break-word; overflow-wrap: anywhere;">
        <p style="margin-top: 0;">このツールは <b>Google Slides API</b> を使うと、図やグラフを<b>あとから編集できる形</b>でスライドに書き出せます。</p>
        <p>現在この機能が有効になっていないため、図やグラフは<b>画像として貼り付け</b>られます（スライド上で直接の編集はできません）。このままでも作成・利用に問題はありません。</p>
        <p style="font-weight:600; margin: 1.25rem 0 0.5rem;">編集できる形にする手順</p>
        <ol style="margin: 0; padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.45rem;">
          <li>Apps Script エディタを開く</li>
          <li>左メニューの「サービス」の「＋」を押し、一覧から <b>「Google Slides API」</b> を選んで「追加」</li>
        </ol>
        <p style="color:#6b7280; font-size:0.825rem; margin: 1rem 0 0;">※ サービスに追加するだけで有効になります（デプロイの更新は不要です）。追加後に作成するスライドから反映されます。</p>
        <div class="button-group" style="margin-top: 1.5rem; display: flex; gap: 0.75rem; justify-content: flex-end;">
          <button id="slidesApiModalCloseBtn" type="button" class="btn btn-primary" style="width: auto; padding: 0.5rem 1.5rem;">閉じる</button>
        </div>
      </div>
    </div>
  </div>

  <!-- 新5: 管理者の設定モーダル -->
  <div id="adminSettingsModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 520px; max-height: 90vh;">
      <h3 class="card-header">管理者の設定</h3>
      <div class="card-body">
        <p style="font-size: 0.875rem; color: #6b7280; margin-top: 0;">
          このアプリの管理者メールアドレスを登録・編集します。管理者は管理者メニュー（縦3点）を利用できます。
          最初の管理者は、最初にGemを登録したユーザーが自動登録されています。
        </p>
        <div id="adminEmailList" style="margin: 1rem 0; display: flex; flex-direction: column; gap: 0.5rem;"></div>
        <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
          <input type="email" id="adminEmailNewInput" class="form-control" placeholder="追加するメールアドレス" style="flex: 1;">
          <button id="adminEmailAddBtn" type="button" class="btn-preset" style="flex-shrink: 0;">追加</button>
        </div>
        <small id="adminSettingsError" style="color: var(--danger-color); display: none; margin-top: 0.5rem;"></small>
        <div class="button-group" style="margin-top: 1.5rem; display: flex; gap: 0.75rem; justify-content: flex-end;">
          <button id="adminSettingsCancelBtn" type="button" class="btn-preset">キャンセル</button>
          <button id="adminSettingsSaveBtn" type="button" class="btn btn-primary" style="width: auto; padding: 0.5rem 1.25rem;">保存</button>
        </div>
      </div>
    </div>
  </div>

  <!-- 新5: 設定プリセットの共有モーダル -->
  <div id="presetShareModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 560px; max-height: 90vh; overflow-y: auto;">
      <h3 class="card-header">設定プリセットの共有</h3>
      <div class="card-body">
        <p style="font-size: 0.875rem; color: #6b7280; margin-top: 0;">
          デフォルト以外のプリセットを全ユーザーへ共有できます。共有されたプリセットは、他ユーザーのプリセット一覧の「共有プリセット」グループに表示されます。
        </p>

        <h4 style="margin: 1rem 0 0.5rem; font-size: 0.95rem;">自分のプリセット</h4>
        <div id="presetShareOwnList" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>

        <h4 style="margin: 1.25rem 0 0.5rem; font-size: 0.95rem;">現在共有中のプリセット</h4>
        <div id="presetShareSharedList" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>

        <small id="presetShareError" style="color: var(--danger-color); display: none; margin-top: 0.5rem;"></small>
        <div class="button-group" style="margin-top: 1.5rem; display: flex; justify-content: flex-end;">
          <button id="presetShareCloseBtn" type="button" class="btn btn-primary" style="width: auto; padding: 0.5rem 1.25rem;">閉じる</button>
        </div>
      </div>
    </div>
  </div>

  <div id="imageUpdateOptionModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 450px;">
      <h3 class="card-header">画像オプションの選択</h3>
      <div class="card-body">
        <p style="margin-bottom: 1.5rem;">スライド内の画像をどのように更新しますか？</p>
        <div class="button-group" style="display: flex; flex-direction: column; gap: 1rem;">
          <button id="imageOptionUpdateBtn" class="btn btn-primary">画像も更新する</button>
          <button id="imageOptionKeepBtn" class="btn btn-secondary" style="background-color: var(--secondary-color);">画像をそのまま使う</button>
          <button id="imageOptionCancelBtn" class="btn" style="background-color: #f3f4f6; color: var(--text-secondary); margin-top: 1rem;">キャンセル</button>
        </div>
      </div>
    </div>
  </div>


  <div id="pdfPromptModal" class="modal-overlay" style="display: none; z-index: 10015;">
    <div class="modal-content card" style="max-width: 500px;">
      <h3 class="card-header">PDFファイルの選択 (Gemini Notebook)</h3>
      <div class="card-body">
        <p id="pdfPromptText" style="margin-bottom: 1.5rem; line-height: 1.5; font-weight: bold; font-size: 1.1rem; text-align: center;"></p>

        <div class="form-group" style="margin-bottom: 1.5rem; text-align: left;">
            <label for="pdfLayoutSelect" style="font-weight: 600; font-size: 0.875rem; color: var(--text-secondary); display: block; margin-bottom: 0.5rem;">
              レイアウト選択
            </label>
            <select id="pdfLayoutSelect" class="form-control">
              <option value="pdf_layout">PDFレイアウト</option>
              <option value="full_layout_with_margins" selected>まじん式レイアウト</option>
            </select>
            
            <div id="pdfPageSettingsArea" style="display: none;"></div>
            <div id="pdfPageListContainer" style="display: none;"></div>
        </div>

        <div id="pdfDropZone" class="drop-zone">
          <canvas id="pdfPreviewCanvas" class="image-preview" style="display:none;"></canvas>
          <span id="pdfDropZoneText">ここにPDFファイルをドラッグ＆ドロップ、<br>
            <a href="#" id="fileSelectLinkPdf" style="color: var(--primary-color); text-decoration: underline;">クリックしてファイルを選択</a><br>
            またはPDFファイルをペーストしてください。
          </span>
        </div>
        
        <div class="button-group" style="display: flex; gap: 1rem; margin-top: 1.5rem;">
          <button id="confirmPdfBtn" class="btn btn-primary" style="display: none;" disabled>確定</button>
          
          <button id="cancelPdfBtn" class="btn btn-secondary" style="background-color: var(--secondary-color); width: 100%;">キャンセル</button>
        </div>
      </div>
    </div>
  </div>



  <input type="file" id="pdfUpload" accept="application/pdf" style="display: none;">

  <!-- ★一括画像分解: PDF範囲＋下地トグル モーダル -->
  <div id="batchPdfRangeModal" class="modal-overlay" style="display:none; align-items:center; justify-content:center; z-index:10040;">
    <div class="modal-content card" style="max-width:460px;">
      <h3 class="card-header">PDFから画像分解スライドを作成</h3>
      <div class="card-body">
        <!-- ★説明文(全Nページ…/PDFレイアウトで取り込みます…)はユーザー要望で削除。batchRangeTotal参照はaskRangeでnull安全。 -->
        <!-- ★[要望2] スライド名。デフォルト=PDFファイル名+日付。編集可。生成デッキ名に反映される。 -->
        <div class="form-group" style="margin-bottom:1rem;">
          <label for="batchRangeTitle">スライド名</label>
          <input type="text" id="batchRangeTitle" class="form-control" placeholder="スライド名を入力">
        </div>
        <div class="form-grid" style="margin-bottom:1rem;">
          <div class="form-group"><label for="batchRangeStart">開始ページ</label><select id="batchRangeStart" class="form-control"></select></div>
          <div class="form-group"><label for="batchRangeEnd">終了ページ</label><select id="batchRangeEnd" class="form-control"></select></div>
        </div>
        <div class="button-group" style="display:flex; gap:1rem;">
          <button id="batchRangeOk" type="button" class="btn btn-primary">解析処理開始</button>
          <button id="batchRangeCancel" type="button" class="btn btn-secondary" style="background-color:var(--secondary-color);">キャンセル</button>
        </div>
      </div>
    </div>
  </div>
  <input type="file" id="batchPdfFileInput" accept="application/pdf" style="display:none;">

  <!-- ★ⓘ 情報モーダル: ⓘボタンのクリックで全文を表示 -->
  <div id="infoTipModal" class="modal-overlay" style="display:none; align-items:center; justify-content:center; z-index:10060;">
    <div class="modal-content card" style="max-width:520px;">
      <h3 class="card-header">説明</h3>
      <div class="card-body">
        <div id="infoTipModalBody" style="font-size:0.9rem; line-height:1.75; color:var(--text-secondary,#374151); white-space:normal;"></div>
        <div class="button-group" style="margin-top:1.25rem; display:flex; justify-content:flex-end;">
          <button id="infoTipModalClose" type="button" class="btn btn-primary" style="width:auto; padding:0.5rem 1.25rem;">閉じる</button>
        </div>
      </div>
    </div>
  </div>


  <div id="videoPromptModal" class="modal-overlay" style="display: none;">
    <!-- ★[新規A] モーダル自体を横長に -->
    <div class="modal-content card" style="max-width: 760px; width: 90%;">
      <h3 class="card-header">動画ファイルの選択</h3>
      <div class="card-body">
        <p id="videoPromptText" style="margin-bottom: 1rem;">スライドで使用する動画ファイルを選択してください。</p>
        <div id="videoDropZone" class="drop-zone">
          <video id="videoPreview" controls class="image-preview" style="display:none; max-height: 250px;"></video>
          <span id="videoDropZoneText">ここに動画ファイルをドラッグ＆ドロップ、<br>
            <a href="#" id="fileSelectLinkVideo" style="color: var(--primary-color); text-decoration: underline;">クリックしてファイルを選択</a><br>
            または動画ファイルをペーストしてください。
          </span>
          <!-- ★[新規A] 画質・変換種類の2つの選択リストを横並びに -->
          <div id="videoQualityContainer" style="margin-top: 1rem; text-align: left; display: none;">
            <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
              <div style="flex: 1; min-width: 180px;">
                <label for="videoQualitySelect" style="font-weight: 500; font-size: 0.875rem; color: var(--text-secondary);">アニメーション画質 (短辺)</label>
                <select id="videoQualitySelect" class="form-control" style="margin-top: 0.25rem;">
                  <option value="240">最小 (240px)</option>
                  <option value="360">軽量 (360px)</option>
                  <option value="480" selected>標準 (480px)</option>
                  <option value="720">最高 (720px)</option>
                </select>
              </div>
              <!-- ★[新1] アニメーション変換の種類(APNG/GIF) -->
              <div style="flex: 1; min-width: 180px;">
                <label for="videoAnimFormatSelect" style="font-weight: 500; font-size: 0.875rem; color: var(--text-secondary);">アニメーション変換の種類</label>
                <select id="videoAnimFormatSelect" class="form-control" style="margin-top: 0.25rem;">
                  <option value="apng" selected>APNG（デフォルト）</option>
                  <option value="gif">GIFアニメ（Vids動画変換対応）</option>
                </select>
              </div>
            </div>
          </div>
        </div>
        <!-- ★[新規A] 確定・続行ボタンを横並びに(flex:1で均等) -->
        <div class="button-group" style="display: flex; flex-wrap: nowrap; gap: 1rem; margin-top: 1.5rem;">
          <button id="confirmVideoBtn" class="btn btn-primary" style="flex: 1;" disabled>確定</button>
          <button id="cancelVideoBtn" class="btn btn-secondary" style="flex: 1; background-color: var(--secondary-color);">動画なしで続行</button>
        </div>
      </div>
    </div>
  </div>

  <input type="file" id="imageUpload" accept="image/*" style="display: none;">
  <input type="file" id="videoUpload" style="display: none;" accept="video/*">

  <div id="previewInfoModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 500px;">
      <h3 class="card-header">プレビュー版について</h3>
      <div class="card-body">
        <p>これは現在開発中のプレビュー版です。以下の点にご注意ください：</p>
        <ul style="padding-left: 20px; margin-top: 1rem; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.6;">
          <li>機能が予期せず変更・停止される場合があります。</li>
          <li>データが失われる可能性があるため、重要なデータは別途バックアップしてください。</li>
          <li>不具合やフィードバックがあれば、開発者までご連絡ください。</li>
          
          <li style="color: var(--danger-color, #EF4444); font-weight: 500; margin-top: 0.5rem;">
            プレビュー版のコードの改変・流用・第三者への共有は禁止させていただきます。
          </li>
          </ul>
        
        <div id="previewInfoModal_FirstTimeMessage" style="display: none; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
          </div>

        <div class="button-group" style="margin-top: 1.5rem;">
          <button id="closePreviewInfoModalBtn" class="btn btn-primary">閉じる</button>
        </div>
      </div>
    </div>
  </div>


  <div id="historyRestoreModal" class="modal-overlay" style="display: none; z-index: 10020;">
  <div class="modal-content card" style="max-width: 600px; height: auto; max-height: 90vh; display: flex; flex-direction: column;">
    <h3 class="card-header">スライドデータの読み込み</h3>
    
    <div class="card-body" style="overflow-y: auto; padding: 1.5rem;">
      
      <div style="margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color);">
        <label style="font-weight: 600; display: block; margin-bottom: 0.5rem;">スライドID または URL</label>
        <div style="display: flex; gap: 0.5rem;">
          <input type="text" id="historyInputId" class="form-control" placeholder="https://docs.google.com/presentation/d/..." style="flex-grow: 1;">
          <button id="historyLoadBtn" class="btn btn-primary" style="width: auto; white-space: nowrap;">読み込む</button>
        </div>
        <p style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.5rem;">
          URLを貼り付けると自動的にIDが抽出されます。
        </p>
      </div>

      <div>
        <label style="font-weight: 600; display: block; margin-bottom: 0.5rem;">最近使用したスライド</label>
        <div id="historyListContainer" style="border: 1px solid var(--border-color); border-radius: var(--radius-md); max-height: 300px; overflow-y: auto; background: #F9FAFB;">
          <div style="padding: 2rem; text-align: center; color: var(--text-light);">履歴を読み込み中...</div>
        </div>
      </div>

    </div>

    <div class="card-body" style="padding: 1rem; border-top: 1px solid var(--border-color); flex-shrink: 0; display: flex; justify-content: flex-end;">
      <button id="historyCloseBtn" class="btn btn-secondary" style="width: auto;">閉じる</button>
    </div>
  </div>
</div>


  <script>
    // --- JavaScript ---
    // ★ 既定でライセンス確認・上部バナー(previewHeader)・activationゲート・スライドクレジットを無効化（クレジット表示版ライセンスを使わない既定）。
    const ENABLE_CREDIT_IMAGE = false;
    const CREDIT_TEXT_OPTIONS = [
      'Change the world. majin',
      'Accessible AI for All. majin',
      'Automate & Focus. majin',
      'Work Creatively. majin',
      'AI boosts Creativity. majin',
      'Reimagine Your Work. majin'
    ];
    // --- [追加] プレビュー状態管理 ---
    var isPreviewVisible = false;
    var hasShownFirstTimePreviewModal = localStorage.getItem('hasShownPreviewModal_v1') === 'true'; // [新規追加]
    var lastSplitRatio = '1fr 5px 1fr'; // デフォルトの分割比率
    var isSlideFullscreen = false;
    var loadedSettings = {};
    var lastGeneratedSlideUrl = null;
    // ★[要望3] 初回スライド生成が完全に完了するまで true。生成中は「○ページ目を更新」ボタンを出さない。
    window.isInitialGenerating = false;
    // ★[要望4] スライド全体更新(generate)とページ単位更新(updateCurrentPage)の排他制御。
    //   処理中は両ボタンを disabled にし、実行中でない側(相手)には btn-locked を付けてグレーアウト表示する。
    window.isSlideJobRunning = false;
    function setSlideJobRunning(running, runningBtnId) {
        window.isSlideJobRunning = !!running;
        ['generateBtn', 'updatePageBtn'].forEach(function (id) {
            var b = document.getElementById(id);
            if (!b) return;
            if (running) {
                b.disabled = true;
                if (id !== runningBtnId) b.classList.add('btn-locked'); // 相手=処理中で押せない(グレー)
                else b.classList.remove('btn-locked');                  // 実行中の本人はタイマー/波アニメ表示のまま
            } else {
                b.disabled = false;
                b.classList.remove('btn-locked');
            }
        });
    }
    try { loadedSettings = <?!= JSON.stringify(loadSettings()) ?> || {}; } catch (e) {}
    var enableUiColorSync = false; // true: 連動する (デフォルト), false: 連動しない
    var timerInterval = null;
    var timerStartTime = 0;
    var isTimerPaused = false; // ★ この行を追加
    var lastElapsedSeconds = 0.0; // ★ この行を追加
    var cachedHistoryList = null;
    var cachedNotebookLmPdf = null;
    var cachedPdfSplitSettings = {};

    function $(id){ return document.getElementById(id); }

    // --- [追加] プレビュー表示/非表示 関数 ---
    function showPreviewPane() {
      const appLayout = $('app-layout');
      const previewView = $('slidePreviewContainer');
      const splitter = $('splitter');
      const iframe = $('slidePreviewFrame');
      const showBtn = $('showLastSlideBtn');
      const fullscreenBtnContainer = $('fullscreenToggleContainer'); // ★ 追加
      const fullscreenIcon = $('fullscreenIcon'); // ★ 追加

      const newTabBtn = $('openSlideNewTabBtn');

      if (isPreviewVisible && !isSlideFullscreen) return; // ★ 修正: 全画面時は除外
      
      if (lastGeneratedSlideUrl) {
        let embedUrl = lastGeneratedSlideUrl; // ★編集可能プレビュー(@47): /editのまま埋め込む(以前は.replace('/edit','/embed')で閲覧専用プレーヤー化していた)
        if (iframe.src !== embedUrl) { // ★ 修正: URLが違う場合のみ更新
          iframe.src = embedUrl;
        }
        if (newTabBtn) {
          newTabBtn.href = lastGeneratedSlideUrl; // 念のためhrefも更新
          newTabBtn.style.display = 'inline-flex'; // ボタンを表示
        }
      } else {
        showStatus('エラー: 表示するスライドがありません。', 'error');
        return;
      }

      $('closePreviewBtn').style.display = 'block'; /* ← この行を追加 */
      $('closeSettingsBtn').style.display = 'block'; /* ← この行を追加 */

      previewView.style.display = 'block';
      splitter.style.display = 'block';
      fullscreenBtnContainer.style.display = 'block'; // ★ 追加: 全画面ボタン表示
      
      appLayout.style.gridTemplateColumns = lastSplitRatio; // ★ 修正: 常に lastSplitRatio を適用
      
      /* ▼▼▼ [修正] ボタン制御を updatePreviewButtonForEditor に一任するため削除 ▼▼▼
      if (showBtn) {
        showBtn.textContent = 'プレビューを閉じる';
showBtn.classList.remove('btn-primary');
        showBtn.classList.add('btn-secondary');
        showBtn.style.backgroundColor = 'var(--secondary-color)';
      }
      */
      
      // ★ 追加: 全画面状態をリセット
      isSlideFullscreen = false;
      fullscreenIcon.textContent = '>';
      $('left-pane').style.display = 'flex'; // ★ 追加: left-pane を確実に戻す

      isPreviewVisible = true;

      // ▼▼▼ [移植] 左下のボタンを隠す ▼▼▼
      const showPreviewBtnLeftContainer = $('showPreviewBtnLeftContainer');
      if (showPreviewBtnLeftContainer) {
        showPreviewBtnLeftContainer.style.display = 'none';
      }
    }

    // ★★★ [差し替え] hidePreviewPane 関数 (重複ロジックをクリーンアップ) ★★★
    function hidePreviewPane() {
      const appLayout = $('app-layout');
      const previewView = $('slidePreviewContainer');
      const splitter = $('splitter');
      const iframe = $('slidePreviewFrame');
      const showBtn = $('showLastSlideBtn');
      const fullscreenBtnContainer = $('fullscreenToggleContainer');
      const newTabBtn = $('openSlideNewTabBtn');

      if (!isPreviewVisible) return; // 既に非表示

      if (newTabBtn) {
        newTabBtn.style.display = 'none'; // ボタンを非表示
      }

      lastSplitRatio = appLayout.style.gridTemplateColumns; // 閉じる直前の比率を保存
      
      previewView.style.display = 'none';
      splitter.style.display = 'none';

      $('closePreviewBtn').style.display = 'none';
      $('closeSettingsBtn').style.display = 'none';

      fullscreenBtnContainer.style.display = 'none';
      iframe.src = 'about:blank'; // iframeの読み込みを停止
      
      // グリッド幅をリセット
      appLayout.style.gridTemplateColumns = '1fr';
      
      // --- ▼▼▼ [修正] プレビュー状態とボタンの更新ロジック ▼▼▼ ---
      
      isSlideFullscreen = false; // (1) 全画面状態をリセット
      isPreviewVisible = false;  // (2) プレビュー状態を「非表示」に設定

      // (3) 編集モードかテキストモードか確認
      if (SlideEditor && SlideEditor.isEditorActive()) {
        // (4a) 編集モードの場合：
        // 編集モード用のボタン更新関数を呼び出す
        // (この関数が isPreviewVisible=false を見て「プレビューを表示」ボタンを正しく設定します)
        SlideEditor.updatePreviewButtonForEditor(true);
      } else {
        // (4b) テキストモードの場合：
        // 従来通り、手動でボタンのテキストを「スライドを表示」に戻す
        if (showBtn) {
          showBtn.textContent = 'スライドを表示';
          showBtn.classList.add('btn-primary');
          showBtn.classList.remove('btn-secondary');
          showBtn.style.backgroundColor = '';
        }
      }
      // --- ▲▲▲ [修正] ここまで ▲▲▲ ---
    }
// ★★★ [差し替え] ここまで ★★★

    // ▼▼▼ ここから追加 ▼▼▼

    /**
     * プレビューの全画面表示を切り替える
     */
    function toggleFullscreenPreview() {
      const appLayout = $('app-layout');
      const leftPane = $('left-pane');
      const splitter = $('splitter');
      const fullscreenIcon = $('fullscreenIcon');
      const showBtn = $('showLastSlideBtn'); // メインの表示/非表示ボタン
      const toastContainer = $('toastContainer'); // ★ トーストコンテナを取得 ★

      isSlideFullscreen = !isSlideFullscreen; // 状態を反転

      if (isSlideFullscreen) {
        // --- 全画面にする ---
        console.log('Entering fullscreen preview');
        // 1. 現在の比率を保存
        lastSplitRatio = appLayout.style.gridTemplateColumns;
        
        // 2. レイアウト変更 (左ペインとスプリッターを非表示)
        appLayout.style.gridTemplateColumns = '1fr';
        leftPane.style.display = 'none';
        splitter.style.display = 'none';

        $('closePreviewBtn').style.display = 'none'; /* ← この行を追加 */
        $('closeSettingsBtn').style.display = 'none'; /* ← この行を追加 */
        
        // 3. アイコン変更
        fullscreenIcon.textContent = '<';
        
        // 4. メインの「プレビューを閉じる」ボタンを非表示にする
        if (showBtn) {
          showBtn.style.display = 'none';
        }
        
        // 5. ★ トーストコンテナを非表示にする ★
        if (toastContainer) {
          toastContainer.style.display = 'none';
        }

      } else {
        // --- 全画面を解除する (スプリットビューに戻る) ---
        console.log('Exiting fullscreen preview');
        
        // 1. レイアウトを復元 (保存した比率に戻す)
        appLayout.style.gridTemplateColumns = lastSplitRatio;
        leftPane.style.display = 'flex'; // 'flex' に戻す
        splitter.style.display = 'block';

        $('closePreviewBtn').style.display = 'block'; /* ← この行を追加 */
        $('closeSettingsBtn').style.display = 'block'; /* ← この行を追加 */
        
        // 2. アイコン変更
        fullscreenIcon.textContent = '>';
        
        // 3. ★ トーストコンテナを再表示する ★
        if (toastContainer) {
          toastContainer.style.display = 'flex'; // 元の 'flex' に戻す
        }

        // 4. メインの「プレビューを閉じる」ボタンを再表示
        if (showBtn) {
          // 50ミリ秒遅延させてボタンを表示
          setTimeout(() => {
            if (showBtn) {
              showBtn.style.display = 'inline-flex';
              showBtn.style.zIndex = '100'; 

              if (SlideEditor && SlideEditor.isEditorActive()) {
                // 編集モードの場合：
                // onclick は割り当てず、SlideEditorにボタン状態の更新を依頼する
                SlideEditor.updatePreviewButtonForEditor(true);
              } else {
                // テキストモードの場合：
                // 従来の「表示/非表示」トグル動作を割り当てる
                showBtn.onclick = function() { 
                  if (isPreviewVisible) hidePreviewPane(); else showPreviewPane();
                };
              }
              console.log('Button display and listener reset complete.');

            }
          }, 50); // 50ミリ秒の遅延
        }
      }
    }

      // 全画面切り替えボタンにイベントリスナーを設定
      const toggleFullscreenBtn = $('toggleFullscreenBtn');
      if (toggleFullscreenBtn) {
        toggleFullscreenBtn.addEventListener('click', toggleFullscreenPreview);
      }
      
      // ▲▲▲ 追加ここまで ▲▲▲

    function adjustTextAreaHeight() {
      const textArea = $('slideDataInput');
      if (!textArea) return;

      try {
        const leftPane = $('left-pane');
        
        // narrow-layout クラスが付いているか、ウィンドウ幅が狭い(フォールバック)かチェック
        const isNarrow = (leftPane && leftPane.classList.contains('narrow-layout')) || (window.innerWidth < 650);
        
        if (isNarrow) {
          // 幅が狭い時は高さを300pxに固定
          textArea.style.height = '150px';
        } else {
          // 幅が広い時は高さを調整
          const settingsCard = document.querySelector('.settings-section');
          const leftColumn = document.querySelector('.left-column');
          
          if (!settingsCard || !leftColumn) { 
            // 必要な要素がなければ、ここで終了 (クラス追加は finally で行う)
            return; 
          }
          
          const targetHeight = settingsCard.offsetHeight;
          const currentEditorHeight = leftColumn.offsetHeight;
          const heightDifference = targetHeight - currentEditorHeight;
          const currentTextAreaHeight = textArea.offsetHeight; // 250px が入る
          const newTextAreaHeight = currentTextAreaHeight + heightDifference;
          
          if (newTextAreaHeight > 50) {
            textArea.style.height = newTextAreaHeight + 'px';
          }
        }
      } catch (e) {
        console.error("Error during adjustTextAreaHeight:", e);
      } finally {
        // ▼▼▼【変更点】▼▼▼
        // 調整が完了（または失敗）したら、表示クラスを追加して表示する
        textArea.classList.add('height-adjusted');
        // ▲▲▲【変更点】▲▲▲
      }
    }
    
    function hexToRgb(hex) {
      // 既存の hexToRgb 関数 (コード.gs からコピー)
      let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
      return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
      } : null;
    }

    /**
     * 色を暗くする関数 (クライアントサイド版)
     * @param {string} color - 元の色 (#RRGGBB形式)
     * @param {number} amount - 暗くする量 (0.0-1.0、大きいほど暗く)
     * @return {string} 暗くした色
     */
    function darkenColor(color, amount) {
      const rgb = hexToRgb(color);
      if (!rgb) return color; // 不正な色ならそのまま返す

      const darken = (c) => Math.max(0, Math.round(c * (1 - amount))); // 0未満にならないように
      const newR = darken(rgb.r);
      const newG = darken(rgb.g);
      const newB = darken(rgb.b);

      // #RRGGBB 形式で返す
      return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
    }

    function updatePrimaryColor(newColor) {
      if (!/^#([0-9A-F]{3}){1,2}$/i.test(newColor)) return;
      
      $('primaryColor').value = newColor;
      $('primaryColorText').value = newColor;
      // if (!enableUiColorSync) return; // UI連動フラグを無視 (前回の修正)
      document.documentElement.style.setProperty('--primary-color', newColor);

      try {
        // [変更] 既存の hexToRgb 関数を利用します
        const rgb = hexToRgb(newColor); // {r, g, b} が返る
        
        if (rgb) {
          // ▼▼▼ [移植] RGB値をCSS変数として設定 ▼▼▼
          document.documentElement.style.setProperty('--primary-color-rgb', `${rgb.r}, ${rgb.g}, ${rgb.b}`);
          // ▲▲▲ [移植] ここまで ▲▲▲

          // [変更] ダークカラーの計算も rgb を使用
          const r = Math.floor(rgb.r * 0.85);
          const g = Math.floor(rgb.g * 0.85);
          const b = Math.floor(rgb.b * 0.85);
          const darkColor = "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
          document.documentElement.style.setProperty('--primary-color-dark', darkColor);
        } else {
           throw new Error('hexToRgb failed');
        }
      } catch (e) {
        // フォールバック
        document.documentElement.style.setProperty('--primary-color-dark', newColor);
        // RGB変数のフォールバックも追加
        document.documentElement.style.setProperty('--primary-color-rgb', '66, 133, 244'); // デフォルトの青
      }
    }

    // function hexToRgb(hex) {
    //   let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    //   return result ? {
    //     r: parseInt(result[1], 16),
    //     g: parseInt(result[2], 16),
    //     b: parseInt(result[3], 16)
    //   } : null;
    // }

    // ★[トースト方針 / 回帰防止] 右下トースト。種別(type)と文言の基準は以下を厳守すること:
    //   ・種別の境界: warning=処理は続行(一部失敗→代替/スキップ/自動再試行。catch→showStatus→return null + 呼出元 filter(Boolean))/
    //                 error=処理が中断しユーザー操作が必要(catch→throw/return で generate 中断)/
    //                 info=進行中・案内・ユーザー起因のキャンセル(文言は「中止しました」。続行する「スキップ」とは語を分ける)/ success=正常完了。
    //   ・文言: 1トースト1メッセージ・短文(目安40字)・動詞中心。トーストに出さない=MB/px・件数・変数名・コーデック名(APNG/GIF)・スタック・生e.message(→console.errorへ)。
    //   ・専門語はユーザー語へ(SVG→図形/矢印画像, JSON→データ, APNG/GIF→アニメーション)。先頭絵文字「⚠/❌」と接頭辞「エラー:/警告:」は付けない(色で区別)。
    //   ・durationは現状フェード時間に影響しない(.toast の toast-out animation 固定3.5s)。DOM削除タイミングのみ。
    //   ・別系統: PDFモーダル内 showStatus(msg,className)(別関数)/OCR進捗 showNotification は本関数と無関係。
    function showStatus(message, type = 'info', duration = 4000){
      const container = $('toastContainer');
      if(!container) return null; // ★ null を返すように変更

      const toast = document.createElement('div');
      const toastId = 'toast-' + Date.now() + Math.random(); // ★ ユニークIDを生成
      toast.id = toastId; // ★ IDを要素に設定
      toast.className = `toast toast-${type}`;
      toast.innerHTML = message;

      if (type === 'info') {
        const __pcEl = $('primaryColorText');
        const primaryColor = __pcEl ? __pcEl.value : '#4285F4';
        const rgb = hexToRgb(primaryColor);
        if (rgb) {
          toast.style.backgroundColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.1)`;
          toast.style.color = `rgb(${Math.floor(rgb.r*0.4)}, ${Math.floor(rgb.g*0.4)}, ${Math.floor(rgb.b*0.4)})`;
          toast.style.borderColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.3)`;
        }
      }

      container.appendChild(toast);
      setTimeout(() => {
        // 削除時に要素が存在するか確認
        const elementToRemove = $(toastId);
        if (elementToRemove) {
          elementToRemove.remove();
        }
      }, duration);
      
      return toastId; // ★ IDを返す
    }


    /* ▼▼▼ [新規追加] ここから ▼▼▼ */
    /**
     * スライドデータ (全体または単体) を受け取り、
     * データ内のすべての配列から空文字列 ("") を再帰的に削除します。
     * @param {object | object[]} data - スライドデータ (全体または単体)
     * @returns {object | object[]} フィルタリングされたデータ
     */
    function filterEmptyStringsInArrays(data) {
        
        /**
         * データを再帰的に処理するヘルパー
         * @param {*} obj - 現在処理中のデータ (オブジェクト、配列、文字列など)
         */
        function deepFilter(obj) {
            if (Array.isArray(obj)) {
                // 1. 配列の場合:
                //    各要素を再帰的に処理 (deepFilter) し、
                //    その結果が空文字列 ("") でないものだけを filter して新しい配列を返す
                return obj
                    .map(item => deepFilter(item)) 
                    .filter(item => item !== ""); 
            } 
            else if (typeof obj === 'object' && obj !== null) {
                // 2. オブジェクトの場合:
                //    新しいオブジェクトを作成し、すべてのキーの値を再帰的に処理 (deepFilter) してセットする
                const newObj = {};
                for (const key in obj) {
                    if (Object.prototype.hasOwnProperty.call(obj, key)) {
                        newObj[key] = deepFilter(obj[key]);
                    }
                }
                return newObj;
            }
            // 3. 文字列、数値、null などはそのまま返す
            return obj;
        }

        // --- 実行 ---
        if (Array.isArray(data)) {
            // slideData (全体配列) の場合
            return data.map(slide => deepFilter(slide));
        } else if (typeof data === 'object' && data !== null) {
            // slide (単体オブジェクト) の場合
            return deepFilter(data);
        }
        
        return data; // それ以外
    }


    function startButtonTimer(buttonElement) {
      if (timerInterval) {
        clearInterval(timerInterval); // 既存のタイマーをクリア
      }
      
      const timerSpan = buttonElement.querySelector('.btn-timer');
      const textSpan = buttonElement.querySelector('.btn-text');
      if (!timerSpan || !textSpan) return;

      timerStartTime = Date.now();
      lastElapsedSeconds = 0.0; // ★ 変更: 経過時間をリセット
      isTimerPaused = false; // ★ 変更: タイマーを開始状態に
      
      timerSpan.style.display = 'inline'; // タイマー表示
      timerSpan.textContent = '0.0s';
      
      // ボタン内のテキスト配置を調整
      buttonElement.style.justifyContent = 'center'; 

      timerInterval = setInterval(() => {
        // ▼▼▼ [ここから変更] ▼▼▼
        if (isTimerPaused) {
            return; // 一時停止中は何もせず終了
        }
        // ▲▲▲ [変更ここまで] ▲▲▲

        const elapsed = (Date.now() - timerStartTime) / 1000;
        lastElapsedSeconds = elapsed; // ★ 変更: 経過時間を保存
        timerSpan.textContent = `${elapsed.toFixed(1)}s`;
      }, 100);
    }

    function stopButtonTimer(buttonElement, defaultText) {
      if (timerInterval) {
        clearInterval(timerInterval);
        timerInterval = null;
      }
      
      isTimerPaused = false; // ★ 変更
      lastElapsedSeconds = 0.0; // ★ 変更
      
      const timerSpan = buttonElement.querySelector('.btn-timer');
      const textSpan = buttonElement.querySelector('.btn-text');
      
      if (timerSpan) timerSpan.style.display = 'none'; // タイマー非表示
      if (textSpan && defaultText) textSpan.textContent = defaultText; // テキストを元に戻す
      
      // ボタン内のテキスト配置をデフォルト（中央）に戻す
      buttonElement.style.justifyContent = 'center';
    }

    /**
     * ★ 新規追加: 実行中タイマーを一時停止
     */
    function pauseTimer() {
        if (timerInterval && !isTimerPaused) {
            isTimerPaused = true;
            console.log('Timer paused');
        }
    }

    /**
     * ★ 新規追加: 実行中タイマーを再開（時間補正付き）
     */
    function resumeTimer() {
        if (timerInterval && isTimerPaused) {
            // (現在時刻) - (最後に記録した経過秒数 * 1000) = 新しい開始時刻
            timerStartTime = Date.now() - (lastElapsedSeconds * 1000);
            isTimerPaused = false;
            console.log('Timer resumed');
        }
    }

    /**
     * ★ 新規追加: 既存のモーダル関数をラップしてタイマー制御を追加
     */
    (function wrapModalFunctionsForTimer() {
        // 対象のモーダル関数と、そのモーダルを閉じるための cleanup 関数の名前
        const modalFunctions = {
            'promptForImage': 'cleanup',
            'promptForVideo': 'cleanup',
            'promptForFrameSelection': 'cleanup',
            'promptImageUpdateOption': 'cleanup',
            'showJsonErrorModal': 'closeJsonErrorModal',
            'showClearConfirmModal': 'closeClearConfirmModal',
            'showPreviewInfoModal': 'hidePreviewInfoModal'
        };

        // 各モーダル関数をラップ
        for (const funcName in modalFunctions) {
            if (typeof window[funcName] === 'function') {
                const originalFunc = window[funcName];
                
                // モーダルを開く関数 (例: promptForImage)
                window[funcName] = function() {
                    pauseTimer(); // ★ モーダルを開く直前にタイマー停止
                    
                    const result = originalFunc.apply(this, arguments);
                    
                    // Promiseベースの関数 (prompt系) の場合
                    if (result && typeof result.then === 'function') {
                        // 成功(resolve)でも失敗(reject)でも、必ずタイマーを再開
                        return result.then(
                            (value) => {
                                resumeTimer(); // ★ 閉じた後にタイマー再開
                                return value;
                            },
                            (error) => {
                                resumeTimer(); // ★ 閉じた後にタイマー再開
                                throw error;
                            }
                        );
                    }
                    
                    return result;
                };

                // モーダルを閉じる関数 (例: closeJsonErrorModal)
                const closeFuncName = modalFunctions[funcName];
                if (closeFuncName && typeof window[closeFuncName] === 'function' && closeFuncName !== 'cleanup') {
                     const originalCloseFunc = window[closeFuncName];
                     window[closeFuncName] = function() {
                         originalCloseFunc.apply(this, arguments);
                         resumeTimer(); // ★ 閉じる関数実行後にタイマー再開
                     };
                }
            }
        }
        
        // GemUrlモーダル (DOMContentLoaded内で定義されている) の特殊対応
        document.addEventListener('DOMContentLoaded', () => {
            const saveBtn = $('saveGemUrlBtn');
            const modal = $('gemUrlModal');
            
            // モーダルが自動表示されるケース
            if (modal && modal.style.display === 'flex') {
                pauseTimer();
            }
            
            if (saveBtn) {
                // 保存ボタンハンドラをラップ
                const originalSaveHandler = saveBtn.onclick;
                if (typeof originalSaveHandler === 'function') {
                    saveBtn.onclick = function() {
                        // originalSaveHandler の中で resumeTimer() が呼ばれることを期待
                        // (モーダルを閉じる処理が非同期コールバック内にあるため)
                        originalSaveHandler.apply(this, arguments);
                    };
                }
                
                // (saveGemUrl のコールバック内で resumeTimer を呼ぶ処理を追加)
                const input = $('gemUrlInput');
                const errorEl = $('gemUrlError');
                
                saveBtn.addEventListener('click', function() {
                    const url = input.value.trim();
                    // ★[C1] 旧: google.script.run のProxy監視ブロックは、設定直後に復元されていて一切作用しない完全no-opだったため削除(resumeTimerは各ハンドラ内で必要時に直接呼ぶ)
                });
            }
        });

    })();


    function updateLargeFontColor(newColor) {
      if (!/^#([0-9A-F]{3}){1,2}$/i.test(newColor)) return;
      $('largeFontColor').value = newColor; // ID変更
      $('largeFontColorText').value = newColor; // ID変更
      if (!enableUiColorSync) return;
      document.documentElement.style.setProperty('--text-large-font', newColor); // 変数名変更
    }

    function updateSmallFontColor(newColor) {
      if (!/^#([0-9A-F]{3}){1,2}$/i.test(newColor)) return;
      $('smallFontColor').value = newColor; // 新ID
      $('smallFontColorText').value = newColor; // 新ID
      if (!enableUiColorSync) return;
      document.documentElement.style.setProperty('--text-small-font', newColor); // 新変数名
    }


      function updateBackgroundColor(newColor) {
          // 有効なHEXカラーコードか簡易チェック
          if (!/^#([0-9A-F]{3}){1,2}$/i.test(newColor)) return;
          
          // カラーピッカーとテキスト入力の値は常に同期する
          $('backgroundColor').value = newColor;
          $('backgroundColorText').value = newColor;
          
          if (!enableUiColorSync) return;

          // ▼▼▼【ここから変更】▼▼▼
          // left-pane が narrow-layout クラスを持っているか確認
          const leftPane = $('left-pane');
          const isNarrow = leftPane && leftPane.classList.contains('narrow-layout');
          
          // 幅が狭い時は、CSS変数の更新をスキップする
          // (ResizeObserver側で #F9FAFB に固定されるため)
          if (isNarrow) {
              return;
          }
          
          // 幅が広い時だけ、CSS変数を更新する
          let bodyBgValue;
          let headerBgValue;
          if (newColor.toUpperCase() === '#FFFFFF') {
              bodyBgValue = '#F9FAFB';
              headerBgValue = '#F9FAFB';
          } else {
              bodyBgValue = newColor;
              headerBgValue = darkenColor(newColor, 0.05);
          }
          document.documentElement.style.setProperty('--bg-body', bodyBgValue);
          document.documentElement.style.setProperty('--card-header-bg', headerBgValue);
          // ▲▲▲【変更ここまで】▲▲▲
    }

    // ★カラーテーマ（ビジネス/高級感のある落ち着いた4配色: モノトーン/セピア/ネイビー/ボルドー）
    //  設定プリセットのドロップダウン末尾(optgroup「カラーテーマ」)に表示し、選択で配色のみ一括適用する（独立項目にはしない）。
    //  既存の色更新関数を再利用し、色関連の入力のみ差し替える（チェックボックス/ロゴ/テンプレ等の他設定は保持）。
    //  各色は適用後も個別に微調整可能。getCurrentSettings は入力欄から値を読むため、適用後に「保存」でプリセット化できる。
    //  すべて明るい背景＋低彩度で、可読性とビジネス用途を優先。
    const THEME_PRESET_OPTION_PREFIX = '__theme:'; // presetSelect の option value 接頭辞（プリセット名との衝突回避）
    // ★ カラーテーマは機能(配色データ・applyThemePreset)は温存しつつ、一旦UI(プリセット ドロップダウン)からは非表示にする。
    //   再表示したい時はこのフラグを true に戻すだけでよい。
    const SHOW_THEME_PRESETS_IN_DROPDOWN = false;
    const THEME_PRESETS = {
      monotone: { label: 'モノトーン', primaryColor: '#2E2E2E', largeFontColor: '#1A1A1A', smallFontColor: '#404040', backgroundColor: '#FFFFFF', gradientStart: '#4A4A4A', gradientEnd: '#9E9E9E' },
      sepia:    { label: 'セピア',     primaryColor: '#8B6F47', largeFontColor: '#3E2F1C', smallFontColor: '#5A4632', backgroundColor: '#FAF6EF', gradientStart: '#A98B5D', gradientEnd: '#D9C3A0' },
      navy:     { label: 'ネイビー',   primaryColor: '#1F3A5F', largeFontColor: '#14233A', smallFontColor: '#2A3F5C', backgroundColor: '#F5F7FA', gradientStart: '#1F3A5F', gradientEnd: '#4A6B92' },
      bordeaux: { label: 'ボルドー',   primaryColor: '#7B2D3A', largeFontColor: '#3D1219', smallFontColor: '#52232A', backgroundColor: '#FAF4F2', gradientStart: '#7B2D3A', gradientEnd: '#B0566A' }
    };

    function applyThemePreset(key) {
      const t = THEME_PRESETS[key];
      if (!t) return;
      // 色（ピッカー/テキスト/CSS変数を一括同期。各update関数が両入力を更新する）
      updatePrimaryColor(t.primaryColor);
      updateLargeFontColor(t.largeFontColor);
      updateSmallFontColor(t.smallFontColor);
      updateBackgroundColor(t.backgroundColor);
      // グラデーション（ピッカー/テキスト同期＋プレビュー更新）
      $('gradientStart').value = t.gradientStart;
      $('gradientStartText').value = t.gradientStart;
      $('gradientEnd').value = t.gradientEnd;
      $('gradientEndText').value = t.gradientEnd;
      if (typeof updateGradientPreview === 'function') updateGradientPreview();
      if (typeof showStatus === 'function') showStatus(t.label + ' テーマを適用しました', 'success');
    }
    // インラインonclickはグローバルスコープで実行されるため、window へ明示公開する（executeRestore と同じ規約）。
    window.applyThemePreset = applyThemePreset;

    function getCurrentSettings() {
      return {
        primaryColor: $('primaryColorText').value,
        largeFontColor: $('largeFontColorText').value,
        smallFontColor: $('smallFontColorText').value,
        backgroundColor: $('backgroundColorText').value,
        graphColorTheme: $('graphColorTheme').value,
        fontFamily: $('fontFamily').value,
        showTitleUnderline: $('showTitleUnderline').checked,
        showBottomBar: $('showBottomBar').checked,
        showDateColumn: $('showDateColumn').checked,
        enableGradient: $('enableGradient').checked,
        gradientStart: $('gradientStartText').value,
        gradientEnd: $('gradientEndText').value,
        enableGraphAnimation: $('enableGraphAnimation').checked,
        // ★[新規L] アニメグラフの変換形式(APNG/GIF)を永続化(プリセット保存/再読込で維持)
        graphAnimFormat: $('graphAnimFormatSelect') ? $('graphAnimFormatSelect').value : 'apng',
        footerText: $('footerText').value.trim(),
        headerLogoUrl: $('headerLogoUrl').value.trim(),
        closingLogoUrl: $('closingLogoUrl').value.trim(),
        titleBgUrl: $('titleBgUrl').value.trim(),
        sectionBgUrl: $('sectionBgUrl').value.trim(),
        mainBgUrl: $('mainBgUrl').value.trim(),
        closingBgUrl: $('closingBgUrl').value.trim(),
        templateUrl: $('templateUrl').value.trim(),
        // ★[新規F] 無テンプレ時は案X(単一クリーンマスター)を本番でも有効化し、Office Theme混入(A+B合体の2マスター)を解消。
        //   マスター表示名は「シンプル（明るい）」に統一(Office Theme表示を消す)。@134で masters=1/全BLANK を実証済。
        nativeSingleMaster: true,
        nativeThemeName: 'シンプル（明るい）',
        marginTop: $('marginTop').value.trim(),
        marginBottom: $('marginBottom').value.trim(),
        marginLeft: $('marginLeft').value.trim(),
        marginRight: $('marginRight').value.trim(),
        driveFolderUrl: $('driveFolderUrl').value.trim(),
        showPageNumber: $('showPageNumber').checked,
        // ★ セクションのゴースト数字を編集可能なネイティブ図形にする(default off/実験的)
        nativeSectionMode: $('nativeSectionMode') ? $('nativeSectionMode').checked : false,
        // ★ Phase1: 下部バー等の装飾をネイティブ図形化(default off/実験的)
        nativeDecorMode: $('nativeDecorMode') ? $('nativeDecorMode').checked : false,
        // ★ REST一括テキスト: テキストを Slides REST(batchUpdate)で一括生成(default off/実験的)
        restTextMode: $('restTextMode') ? $('restTextMode').checked : false,
        // ★ nativeChartMode: グラフ/SVGを編集可能なネイティブ図形として配置(default off/実験的)
        nativeChartMode: $('nativeChartMode') ? $('nativeChartMode').checked : false,
        // ★ ゴースト数字(セクション背景の大番号)の縦グラデ透明度。上端/下端(0〜1)。既定 0.30/0.10
        ghostTopAlpha: $('ghostTopAlpha') ? (isNaN(parseFloat($('ghostTopAlpha').value)) ? 0.35 : parseFloat($('ghostTopAlpha').value)) : 0.35,
        ghostBottomAlpha: $('ghostBottomAlpha') ? (isNaN(parseFloat($('ghostBottomAlpha').value)) ? 0.2 : parseFloat($('ghostBottomAlpha').value)) : 0.2,
        // ★ useNotebookLmSplit: 画像分解/テキスト抽出にNotebookLMエンジンを使う(default off/実験的・フロント挙動のみ)
        useNotebookLmSplit: $('useNotebookLmSplit') ? $('useNotebookLmSplit').checked : false,
        // ★ newVideoLogic: 動画アニメ(APNG)を新方式(バウンド付き並列ストリーミング)で生成(default off/実験的)
        newVideoLogic: $('newVideoLogic') ? $('newVideoLogic').checked : false,
        // ★ animationFormat: 動画アニメの出力形式 'apng'(可逆) / 'gif'(256色・小サイズ)。既定'apng'
        animationFormat: ($('animationFormat') && $('animationFormat').value === 'gif') ? 'gif' : 'apng'
        // driveFolderId は generate 関数内で設定されるため、ここでは不要
      };
    }
    





    /**
 * 【新規追加】Canvasの色調を指定色(プライマリ)に統一するフィルタ
 * 輝度(L)は元のピクセルのまま、色相(H)と彩度(S)を指定色に置き換えます。
 * @param {HTMLCanvasElement} canvas - 処理対象のCanvas
 * @param {string} colorHex - プライマリカラー (#RRGGBB)
 */
function applyMonochromeFilter(canvas, colorHex) {
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const height = canvas.height;
  
  // 画像データを取得
  const imageData = ctx.getImageData(0, 0, width, height);
  const data = imageData.data;

  // 指定色をRGBに変換
  const targetRgb = hexToRgb(colorHex);
  if (!targetRgb) return;

  // 指定色をHSLに変換
  const targetHsl = rgbToHsl_Client(targetRgb.r, targetRgb.g, targetRgb.b);

  for (let i = 0; i < data.length; i += 4) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];
    const a = data[i + 3];

    // 透明ピクセルはスキップ
    if (a === 0) continue;

    // 元のピクセルの輝度(L)を計算 (HSL変換)
    const originalHsl = rgbToHsl_Client(r, g, b);
    
    // 元の輝度(L) + 指定色の色相(H)・彩度(S) で新しいRGBを計算
    // ※白(L=100)や黒(L=0)はそのまま維持されます
    const newRgb = hslToRgb_Client(targetHsl.h, targetHsl.s, originalHsl.l);

    data[i] = newRgb.r;
    data[i + 1] = newRgb.g;
    data[i + 2] = newRgb.b;
    // alphaはそのまま
  }

  // 書き戻し
  ctx.putImageData(imageData, 0, 0);
}

/**
 * ★ オブジェクト化(nativeSvg)経路用: SVG文字列内の全カラー(hex/rgb)を
 *   applyMonochromeFilter と同一ロジック(元の輝度Lを保持し、H/Sをプライマリ色に置換)で
 *   プライマリカラー色調へ変換する。白(L=1)・黒(L=0)は不変。
 *   fill/stroke/stop-color、inline style、<style>CSS規則を問わず文字列上の色を一括変換する。
 *   url(#id) 参照(グラデ/フィルタ/クリップ)は退避して保護し、idがhex状でも壊さない。
 * @param {string} svgString
 * @param {string} primaryHex
 * @returns {string}
 */
function recolorSvgToPrimary(svgString, primaryHex) {
  if (!svgString || !primaryHex) return svgString;
  const targetRgb = hexToRgb(primaryHex);
  if (!targetRgb) return svgString;
  const t = rgbToHsl_Client(targetRgb.r, targetRgb.g, targetRgb.b);
  const toHex = (v) => ('0' + Math.max(0, Math.min(255, v)).toString(16)).slice(-2);
  const mapColor = (r, g, b) => {
    const l = rgbToHsl_Client(r, g, b).l;        // 元の輝度を保持
    const o = hslToRgb_Client(t.h, t.s, l);      // プライマリのH/S + 元のL
    return '#' + toHex(o.r) + toHex(o.g) + toHex(o.b);
  };
  // 1) url(...) 参照を退避(idがhex状でも色置換から保護)
  const urlRefs = [];
  let out = String(svgString).replace(/url\([^)]*\)/gi, (m) => {
    urlRefs.push(m); return '@@URLREF' + (urlRefs.length - 1) + '@@';
  });
  // 2) #rrggbb / #rgb
  out = out.replace(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g, (m, hx) => {
    let r, g, b;
    if (hx.length === 3) { r = parseInt(hx[0] + hx[0], 16); g = parseInt(hx[1] + hx[1], 16); b = parseInt(hx[2] + hx[2], 16); }
    else { r = parseInt(hx.slice(0, 2), 16); g = parseInt(hx.slice(2, 4), 16); b = parseInt(hx.slice(4, 6), 16); }
    return mapColor(r, g, b);
  });
  // 3) rgb()/rgba()
  out = out.replace(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/gi, (m, r, g, b, a) => {
    const o = mapColor(+r, +g, +b);
    if (a !== undefined) {
      return 'rgba(' + parseInt(o.slice(1, 3), 16) + ',' + parseInt(o.slice(3, 5), 16) + ',' + parseInt(o.slice(5, 7), 16) + ',' + a + ')';
    }
    return o;
  });
  // 4) url() 参照を復元
  out = out.replace(/@@URLREF(\d+)@@/g, (m, i) => urlRefs[+i]);
  return out;
}

// --- 以下のヘルパー関数も必要であれば追加 (既存にあれば不要) ---

// RGB -> HSL 変換 (クライアント用)
function rgbToHsl_Client(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h, s, l = (max + min) / 2;

  if (max === min) {
    h = s = 0; 
  } else {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2; break;
      case b: h = (r - g) / d + 4; break;
    }
    h /= 6;
  }
  return { h, s, l };
}

// HSL -> RGB 変換 (クライアント用)
function hslToRgb_Client(h, s, l) {
  let r, g, b;
  if (s === 0) {
    r = g = b = l; 
  } else {
    const hue2rgb = (p, q, t) => {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1/6) return p + (q - p) * 6 * t;
      if (t < 1/2) return q;
      if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
      return p;
    };
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
    r = hue2rgb(p, q, h + 1/3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1/3);
  }
  return { 
    r: Math.round(r * 255), 
    g: Math.round(g * 255), 
    b: Math.round(b * 255) 
  };
}








    /**
 * SVG文字列をPNGのBase64データURLに変換する
 * @param {string} svgString - SVGのXML文字列
 * @param {boolean} [shouldTrim=false] - 透明部分をトリミングするかどうか
 * @param {number|null} [maxWidth=null] - 最大幅(px)
 * @param {string|null} [filterColor=null] - ★追加: 適用するカラーフィルタ(HEX)。nullなら適用しない
 * @returns {Promise<string>} PNGのBase64データURL
 */
function convertSvgToBase64Png(svgString, shouldTrim = false, maxWidth = null, filterColor = null) { // ◀ 第4引数を追加
  return new Promise((resolve, reject) => {
    const image = new Image();
        const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
        const url = URL.createObjectURL(svgBlob);

        image.onload = () => {
          const originalCanvas = document.createElement('canvas');
          const originalCtx = originalCanvas.getContext('2d');
          
          // --- 元のサイズ取得ロジック ---
          let originalWidth = image.width;
          let originalHeight = image.height;
          
          if (!originalWidth || !originalHeight || originalWidth <= 0 || originalHeight <= 0) {
            // SVGのviewBoxからサイズ取得を試みる (フォールバック)
            const viewBoxMatch = svgString.match(/viewBox="([\d\s\.-]+)"/);
            if (viewBoxMatch && viewBoxMatch[1]) {
              const parts = viewBoxMatch[1].split(' ');
              if (parts.length === 4) {
                originalWidth = parseFloat(parts[2]);
                originalHeight = parseFloat(parts[3]);
              }
            }
            // それでもダメなら最終フォールバック
            if (!originalWidth || !originalHeight || originalWidth <= 0 || originalHeight <= 0) {
              originalWidth = 500;
              originalHeight = 300;
            }
          }
          // --- 元サイズ取得ここまで ---

          // ▼▼▼ [修正点 1] リサイズロジック ▼▼▼
          let targetWidth, targetHeight;
          
          if (maxWidth !== null && originalWidth > maxWidth) {
            // --- アニメーション/縮小パス ---
            // maxWidth が指定され、元の幅より大きい場合
            targetWidth = maxWidth;
            targetHeight = (originalHeight / originalWidth) * maxWidth;
            // console.log(`[convertSvgToBase64Png] Resizing image from ${originalWidth}x${originalHeight} to ${targetWidth.toFixed(0)}x${targetHeight.toFixed(0)}`);


          } else {
            // --- 静止画/高解像度パス ---
            // maxWidth がないか、元の幅より小さい場合
            
            // ★設定: 短辺の最低ピクセル数
            const minSideTarget = 1200; 
            const baseScale = 2; // 基本の倍率（画質向上のため3～4推奨）

            // 1. 現在の短辺（幅と高さの小さい方）を取得
            const currentMinSide = Math.min(originalWidth, originalHeight);

            // 2. 最低サイズ(1000px)を満たすために必要な倍率を計算
            // (例: 短辺が500pxなら 1000/500 = 2.0倍が必要)
            const requiredScale = minSideTarget / currentMinSide;

            // 3. 「基本倍率」と「必要倍率」のうち、大きい方を採用
            // (これにより、元々大きい画像はbaseScaleで高画質化され、
            //  小さい画像は1000pxまで強制的に引き伸ばされます)
            const finalScale = Math.max(baseScale, requiredScale);

            targetWidth = originalWidth * finalScale;
            targetHeight = originalHeight * finalScale;
          }



          originalCanvas.width = targetWidth;
          originalCanvas.height = targetHeight;

          // 1. 元のSVGを (リサイズ後の) キャンバスに描画
          originalCtx.drawImage(image, 0, 0, originalCanvas.width, originalCanvas.height);
          
          if (filterColor) {
        // プライマリカラーフィルタを適用（元画像を直接書き換える）
        applyMonochromeFilter(originalCanvas, filterColor);
      }

          // ▼▼▼ [修正点 2] トリミングロジック (変更なし) ▼▼▼
          if (shouldTrim) {
            // --- トリミング処理を実行 ---
            try {
              const imageData = originalCtx.getImageData(0, 0, originalCanvas.width, originalCanvas.height);
              const data = imageData.data;
              let minX = originalCanvas.width, minY = originalCanvas.height, maxX = -1, maxY = -1;
              const alphaThreshold = 10;

              // 2. 透明でないピクセルの境界を探す
              for (let y = 0; y < originalCanvas.height; y++) {
                for (let x = 0; x < originalCanvas.width; x++) {
                  const alpha = data[(y * originalCanvas.width + x) * 4 + 3];
                  if (alpha > alphaThreshold) {
                    if (x < minX) minX = x;
                    if (x > maxX) maxX = x;
                    if (y < minY) minY = y;
                    if (y > maxY) maxY = y;
                  }
                }
              }

              // 3. 有効な境界が見つかったかチェック
              if (minX > maxX || minY > maxY) {
                console.warn('トリミング対象の画像が完全に透明です。元の画像を返します。');
                URL.revokeObjectURL(url);
                resolve(originalCanvas.toDataURL('image/png'));
                return;
              }

              // 4. トリミング後のサイズ計算
              const trimWidth = maxX - minX + 1;
              const trimHeight = maxY - minY + 1;

              // 5. 新しいキャンバス作成
              const trimCanvas = document.createElement('canvas');
              trimCanvas.width = trimWidth;
              trimCanvas.height = trimHeight;
              const trimCtx = trimCanvas.getContext('2d');

              // 6. 元のキャンバスからコピー
              trimCtx.drawImage(originalCanvas,
                minX, minY, trimWidth, trimHeight,
                0, 0, trimWidth, trimHeight
              );

              // 7. 新しいキャンバスからBase64取得
              const trimmedPngDataUrl = trimCanvas.toDataURL('image/png');
              URL.revokeObjectURL(url);
              resolve(trimmedPngDataUrl); // トリミング結果を返す

            } catch (trimError) {
              console.error('画像のトリミング処理中にエラー:', trimError);
              URL.revokeObjectURL(url);
              resolve(originalCanvas.toDataURL('image/png')); // エラー時は元の画像を返す
            }
          } else {
            // --- トリミングしない場合 ---
            URL.revokeObjectURL(url);
            resolve(originalCanvas.toDataURL('image/png')); // 元の画像をそのまま返す
          }
          // ▲▲▲ [修正点 2] ここまで ▲▲▲
        };

        image.onerror = (err) => {
          URL.revokeObjectURL(url);
          reject(new Error('SVG画像の読み込みまたは変換に失敗しました。'));
        };

        image.src = url;
      });
    }

    /**
     * ★ 追加: 画像のBase64データに角丸加工を施し、新しいPNG Base64を返す
     * (サンプルコードのロジックを流用)
     * @param {string} base64DataUrl - 元画像のBase64データURL (PNG or JPEG)
     * @param {number} cornerRadiusPercentage - 角丸の半径 (短辺に対する割合、デフォルト5%)
     * @returns {Promise<string>} 角丸加工されたPNGのBase64データURLを返すPromise
     */
    function applyRoundedCorners(base64DataUrl, cornerRadiusPercentage = 3) {
      return new Promise((resolve, reject) => {
        const originalImage = new Image();

        originalImage.onload = () => {
          const w = originalImage.width;
          const h = originalImage.height;

          if (w === 0 || h === 0) {
            reject(new Error('画像の幅または高さが0です。'));
            return;
          }

          // 角の半径 (画像の短辺の X%)
          const cornerRadius = Math.min(w, h) * (cornerRadiusPercentage / 100);

          // 1. 角丸SVG文字列を生成
          // (クリップパスIDが重複しないよう "_gen" サフィックスを追加)
          const svgString = `
<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <clipPath id="roundedCornersClip_gen">
      <rect x="0" y="0" width="${w}" height="${h}" rx="${cornerRadius}" ry="${cornerRadius}" />
    </clipPath>
  </defs>
  <image href="${base64DataUrl}" 
         x="0" y="0" 
         width="${w}" 
         height="${h}" 
         clip-path="url(#roundedCornersClip_gen)" />
</svg>`;
          
          // 2. SVG文字列をPNG Base64に変換
          const svgImage = new Image();
          const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
          const svgUrl = URL.createObjectURL(svgBlob);

          svgImage.onload = () => {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            
            canvas.width = w;
            canvas.height = h;

            ctx.clearRect(0, 0, canvas.width, canvas.height); // 透明度を保持
            ctx.drawImage(svgImage, 0, 0, w, h);

            const pngDataUrl = canvas.toDataURL('image/png');

            URL.revokeObjectURL(svgUrl); // 一時URLを解放
            resolve(pngDataUrl); // 角丸加工済みPNG Base64を返す
          };

          svgImage.onerror = (err) => {
            URL.revokeObjectURL(svgUrl);
            reject(new Error('角丸SVGの画像への読み込みに失敗しました。'));
          };

          svgImage.src = svgUrl;
        };

        originalImage.onerror = (err) => {
          reject(new Error('角丸加工のための元画像の読み込みに失敗しました。'));
        };

        originalImage.src = base64DataUrl;
      });
    }


    /**
 * ★ 修正版: 待機時間を 500ms から 0ms に変更 ★
 * 動的なSVGをiframe内で実行し、静的なSVG文字列に変換する
 * @param {string} dynamicSvgString - JavaScriptを含むSVGのXML文字列
 * @param {number} timeout - タイムアウトまでの時間 (ミリ秒)
 * @returns {Promise<string>} 静的化されたSVG文字列を返すPromise
 */
function renderDynamicSvgInIframe(dynamicSvgString, timeout = 10000) {
  return new Promise((resolve, reject) => {
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.sandbox = 'allow-scripts';

    let timeoutId = null;

    const messageListener = (event) => {
      if (event.source === iframe.contentWindow) {
        if (event.data.type === 'SVG_RENDERED') {
          cleanup();
          resolve(event.data.svg);
        } else if (event.data.type === 'SVG_ERROR') {
          cleanup();
          reject(new Error('iframe内でSVGエラーが発生しました: ' + event.data.error));
        }
      }
    };

    function cleanup() {
      if (timeoutId) clearTimeout(timeoutId);
      window.removeEventListener('message', messageListener);
      if (document.body.contains(iframe)) {
        document.body.removeChild(iframe);
      }
    }

    window.addEventListener('message', messageListener);

    document.body.appendChild(iframe);
    
    const iframeContent = `
      <!DOCTYPE html>
      <html>
        <head><meta charset="utf-8"></head>
        <body style="margin:0; padding:0;">
          ${dynamicSvgString}
          <script>
            /* ▼▼▼ [修正点] 待機時間を 0 に変更 ▼▼▼ */
            setTimeout(() => {
            /* ▲▲▲ [修正点] ここまで ▲▲▲ */
               try {
                const svgElement = document.querySelector('svg');
                if (svgElement) {
                  window.parent.postMessage({ type: 'SVG_RENDERED', svg: svgElement.outerHTML }, '*');
                } else {
                  window.parent.postMessage({ type: 'SVG_ERROR', error: 'SVG element not found' }, '*');
                }
              } catch (e) {
                window.parent.postMessage({ type: 'SVG_ERROR', error: e.message }, '*');
              }
            }, 0); /* <-- 500 から 0 に変更 */
          <\/script>
        </body>
      </html>
    `;
    iframe.srcdoc = iframeContent;
    
    timeoutId = setTimeout(() => {
      cleanup();
      reject(new Error(`SVGのレンダリングがタイムアウトしました (${timeout}ms)`));
    }, timeout);
  });
}

    /**
     * ユーザーに画像のアップロード/ペーストを促すモーダルを表示し、
     * Base64形式の画像データを返すPromiseを生成する
     * @param {string} promptText - モーダルに表示する文章
     * @returns {Promise<string|null>} 解決されるとBase64文字列を返す
     */
    function promptForImage(promptText) {
      return new Promise((resolve, reject) => {
        const modal = $('imagePromptModal');
        const promptElement = $('imagePromptText');
        const dropZone = $('imageDropZone');
        const imageUpload = $('imageUpload');
        const preview = $('imagePreview');
        const dropZoneText = $('dropZoneText');
        const confirmBtn = $('confirmImageBtn');
        const cancelBtn = $('cancelGenerationBtn');

        promptElement.innerHTML = `「<strong>${promptText}</strong>」に合致する画像をアップロードまたは貼り付けてください。`;
        preview.style.display = 'none'; preview.src = '#';
        dropZoneText.style.display = 'block';
        confirmBtn.disabled = true;
        modal.style.display = 'flex';
        let imageData = null;

        function handleFile(file) {
          if (!file || !file.type.startsWith('image/')) {
            showStatus('エラー: 画像ファイルを選択してください。', 'error'); return;
          }
          const reader = new FileReader();
          reader.onload = (e) => {
            imageData = e.target.result;
            preview.src = imageData;
            preview.style.display = 'block';
            dropZoneText.style.display = 'none';
            confirmBtn.disabled = false;
          };
          reader.onerror = () => reject(new Error('ファイルの読み込みに失敗しました。'));
          reader.readAsDataURL(file);
        }

        const clickHandler = (e) => {
          e.preventDefault(); // aタグのデフォルト動作(href="#")を防ぐ
          imageUpload.click();
        };
        const changeHandler = () => { if (imageUpload.files.length > 0) handleFile(imageUpload.files[0]); };
        const dragOverHandler = (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); };
        const dragLeaveHandler = () => dropZone.classList.remove('drag-over');
        const dropHandler = (e) => {
          e.preventDefault(); dropZone.classList.remove('drag-over');
          if (e.dataTransfer.files && e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
        };
        const pasteHandler = (e) => {
          const items = (e.clipboardData || window.clipboardData).items;
          for (const item of items) {
            if (item.kind === 'file' && item.type.startsWith('image/')) {
              e.preventDefault(); handleFile(item.getAsFile()); break;
            }
          }
        };
        const confirmHandler = () => { cleanup(); resolve(imageData); };
        const cancelHandler = () => { cleanup(); reject(new Error('画像なしで続行')); };
        
        function cleanup() {
          $('fileSelectLink').removeEventListener('click', clickHandler); // 解除対象も fileSelectLink に変更
          imageUpload.removeEventListener('change', changeHandler);
          dropZone.removeEventListener('dragover', dragOverHandler);
          dropZone.removeEventListener('dragleave', dragLeaveHandler);
          dropZone.removeEventListener('drop', dropHandler);
          document.removeEventListener('paste', pasteHandler);
          confirmBtn.removeEventListener('click', confirmHandler);
          cancelBtn.removeEventListener('click', cancelHandler);
          modal.style.display = 'none';
          imageUpload.value = '';
        }
        
        $('fileSelectLink').addEventListener('click', clickHandler); // 登録先を fileSelectLink に変更
        imageUpload.addEventListener('change', changeHandler);
        dropZone.addEventListener('dragover', dragOverHandler);
        dropZone.addEventListener('dragleave', dragLeaveHandler);
        dropZone.addEventListener('drop', dropHandler);
        document.addEventListener('paste', pasteHandler);
        confirmBtn.addEventListener('click', confirmHandler);
        cancelBtn.addEventListener('click', cancelHandler);
      });
    }

    function timeStringToSeconds(timeString) {
        const parts = timeString.split(':');
        const minutes = parseInt(parts[0], 10);
        const seconds = parseFloat(parts[1]);
        return minutes * 60 + seconds;
    }

    function seekToTime(video, time, timeoutMs) {
        return new Promise((resolve, reject) => {
            let to = null;
            const cleanup = () => {
                if (to) clearTimeout(to);
                video.removeEventListener('seeked', onSeeked);
                video.removeEventListener('error', onError);
            };
            const onSeeked = () => { cleanup(); resolve(); };
            const onError = (e) => { cleanup(); reject(new Error('動画のシーク中にエラーが発生しました。')); };
            video.addEventListener('seeked', onSeeked);
            video.addEventListener('error', onError);
            // ★ 停滞シーク対策: timeoutMs指定時は期限で必ずreject(呼び出し側はcatchで当該frameをskip)。未指定なら従来どおり待機。
            if (timeoutMs) to = setTimeout(() => { cleanup(); reject(new Error('シークがタイムアウトしました (' + time + 's)')); }, timeoutMs);
            video.currentTime = time;
        });
    }

    /**
   * ★ 追加: 秒数を MM:SS.s 形式の文字列に変換するヘルパー関数
   */
  function secondsToTimeString(totalSeconds) {
    if (totalSeconds < 0) totalSeconds = 0;
    const minutes = Math.floor(totalSeconds / 60);
    const secondsVal = totalSeconds % 60;
    
    const minutesStr = String(minutes).padStart(2, '0');
    // toFixed(1) で小数点第一位まで取得し、padStart(4, '0') で "5.5" -> "05.5" のように整形
    const secondsStr = String(secondsVal.toFixed(1)).padStart(4, '0'); 
    
    return `${minutesStr}:${secondsStr}`;
  }

  /**
   * ★ 追加: フレーム選択モーダル用のCSSを<head>に動的に追加/削除する
   */
  function addFrameSelectionStyles() {
    const styleId = 'frame-selection-styles';
    if (document.getElementById(styleId)) return; // 既に存在する場合は何もしない

    const styles = `
      .frame-selection-grid {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
        gap: 0.75rem;
        max-height: none;
        overflow-y: auto;
        border: 1px solid var(--border-color);
        padding: 0.75rem;
        border-radius: var(--radius-md);
      }
      .frame-candidate {
        position: relative;
        cursor: pointer;
      }
      .frame-candidate img {
        width: 100%;
        height: auto;
        display: block;
        border: 3px solid transparent;
        border-radius: var(--radius-sm);
        transition: border-color 0.2s;
      }
      .frame-candidate input[type="checkbox"] {
        position: absolute;
        top: 8px;
        left: 8px;
        width: 20px;
        height: 20px;
        z-index: 2;
        cursor: pointer;
        accent-color: var(--primary-color);
        /* 影を付けて見やすくする */
        filter: drop-shadow(0 0 2px rgba(0,0,0,0.7));
      }
      .frame-candidate input[type="checkbox"]:checked + img {
        border-color: var(--primary-color);
        box-shadow: 0 0 8px var(--primary-color);
      }
      /* 拡大モーダル */
      .frame-enlarge-modal-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.7);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 2000; /* imagePromptModal(1000)より手前に */
      }
      .frame-enlarge-modal-content {
        position: relative;
        background: var(--bg-surface);
        padding: 1rem;
        border-radius: var(--radius-md);
        box-shadow: var(--shadow-md);
        max-width: 90vw;
        max-height: 90vh;
        display: flex;
        flex-direction: column;
        gap: 1rem;
      }
      .frame-enlarge-modal-content img {
        max-width: 100%;
        max-height: 75vh;
        object-fit: contain;
      }
      .frame-enlarge-modal-buttons {
        display: flex;
        gap: 1rem;
        justify-content: flex-end;
      }
      .frame-candidate.highlighted-range::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        /* プライマリカラー（--primary-color-rgb）の25%透明度で塗りつぶし */
        background-color: rgba(var(--primary-color-rgb, 66, 133, 244), 0.25);
        /* プライマリカラーで破線ボーダー */
        border: 2px dashed var(--primary-color);
        border-radius: var(--radius-sm);
        box-sizing: border-box;
        z-index: 1; /* チェックボックス(z-index: 2)の下に表示 */
      }
    `;

    const styleElement = document.createElement('style');
    styleElement.id = styleId;
    styleElement.type = 'text/css';
    styleElement.appendChild(document.createTextNode(styles));
    document.head.appendChild(styleElement);
  }

  /**
   * ★★★ 修正: 複数のフレーム候補から「1枚または2枚」を選択させるモーダル ★★★
   * 1枚選択時は静止画、2枚選択時はアニメーションとして処理を振り分ける
   * @param {string} promptText - タイムスタンプ (例: "01:23.5")
   * @param {Array<object>} candidateFrames - { timeSec: number, data: string } の配列
   * @returns {Promise<object>} 選択結果 { type: 'static', data: string } または { type: 'animation', frames: Array<object> }
   */
  function promptForFrameSelection(promptText, candidateFrames) {
      return new Promise((resolve, reject) => {
          // --- 1. スタイルと拡大モーダルを準備 (変更なし) ---
          addFrameSelectionStyles();
          
          const enlargeModal = document.createElement('div');
          enlargeModal.id = 'frameEnlargeModal';
          enlargeModal.className = 'frame-enlarge-modal-overlay';
          enlargeModal.style.display = 'none';
          enlargeModal.innerHTML = `
            <div class="frame-enlarge-modal-content">
              <img id="enlargedFrameImg" src="#" alt="Enlarged Frame">
              <div class="frame-enlarge-modal-buttons">
                 <button id="enlargeSelectBtn" class="btn btn-primary">この画像を追加</button>
                 <button id="enlargeCloseBtn" class="btn btn-secondary" style="background-color: var(--secondary-color);">閉じる</button>
              </div>
            </div>
          `;
          document.body.appendChild(enlargeModal);

          const enlargeImg = $('enlargedFrameImg');
          const enlargeSelectBtn = $('enlargeSelectBtn');
          const enlargeCloseBtn = $('enlargeCloseBtn');
          
          // --- 2. 既存の選択モーダルをセットアップ (変更なし) ---
          const modal = $('imagePromptModal');
          const promptElement = $('imagePromptText');

          const modalContent = modal.querySelector('.modal-content');
          const originalModalStyle = {
              maxWidth: modalContent.style.maxWidth,
              maxHeight: modalContent.style.maxHeight
          };
          modalContent.style.maxWidth = '95vw';
          modalContent.style.maxHeight = '95vh';

          const dropZone = $('imageDropZone');
          const preview = $('imagePreview');
          const dropZoneText = $('dropZoneText');
          const confirmBtn = $('confirmImageBtn'); // メインの確定ボタン
          const cancelBtn = $('cancelGenerationBtn');

          cancelBtn.style.display = 'none';
          
          let slideTitle = "該当スライド";
          const timestampToFind = promptText;
          try {
            const slideDataString = $('slideDataInput').value;
            if (slideDataString) {
                const allSlides = JSON.parse(slideDataString);
                const matchingSlide = allSlides.find(slide => slide.image === timestampToFind);
                if (matchingSlide && matchingSlide.title) {
                    slideTitle = matchingSlide.title;
                } else if (matchingSlide) {
                    slideTitle = "タイトルなしスライド";
                }
            }
          } catch (e) {
              console.warn("タイトル検索のためのJSONパースに失敗:", e.message);
          }

          const displayPrompt = `<strong>${slideTitle}</strong> (タイムスタンプ: ${timestampToFind})`;
          
          // ▼▼▼ [変更] 説明文を修正 ▼▼▼
          promptElement.innerHTML = `「${displayPrompt}」に最適なフレームを <strong>1枚 (静止画)</strong> または <strong>2枚 (アニメーション)</strong> 選択してください。<br>(画像クリックで拡大)`;
          // ▲▲▲ [変更] ここまで ▲▲▲

          preview.style.display = 'none';
          dropZoneText.style.display = 'none';
          
          // ▼▼▼ [変更] 確定ボタンの初期状態 ▼▼▼
          confirmBtn.disabled = true;
          confirmBtn.textContent = '画像を選択してください'; // 初期文言
          // ▲▲▲ [変更] ここまで ▲▲▲

          dropZone.innerHTML = ''; // ドロップゾーンの中身をクリア

          const gridContainer = document.createElement('div');
          gridContainer.className = 'frame-selection-grid';
          dropZone.appendChild(gridContainer);
          
          const originalDropZoneStyle = {
              border: dropZone.style.border,
              cursor: dropZone.style.cursor,
              padding: dropZone.style.padding
          };
          dropZone.style.border = 'none'; 
          dropZone.style.cursor = 'default';
          dropZone.style.padding = '0';

          // ▼▼▼ [変更] 選択状態の管理 ▼▼▼
          let selectedFrames = []; // { timeSec: number, data: string, checkbox: HTMLInputElement } の配列
          const allCheckboxes = [];
          // ▲▲▲ [変更] ここまで ▲▲▲
          
          let currentEnlargeSelectHandler = null;

          const middleIndex = Math.floor(candidateFrames.length / 2);
          
          // --- 3. 候補画像をグリッド表示 ---
          candidateFrames.forEach((frame, index) => {
              const candidateDiv = document.createElement('div');
              candidateDiv.className = 'frame-candidate';

              const checkbox = document.createElement('input');
              checkbox.type = 'checkbox';
              checkbox.id = `frame-check-${index}`;
 
              allCheckboxes.push(checkbox);

              // ▼▼▼ [変更] 初期選択ロジック ▼▼▼
              if (index === middleIndex) {
                checkbox.checked = true;
                selectedFrames.push({ ...frame, checkbox: checkbox }); // 新しい
                updateConfirmButtonUI(selectedFrames.length); // ボタンUI更新
              }
              // ▲▲▲ [変更] ここまで ▲▲▲

              const img = document.createElement('img');
              img.src = frame.data;
              img.title = `Time: ${secondsToTimeString(frame.timeSec)}`;
              
              // --- 4. イベントハンドラ設定 ---

              // ▼▼▼ [変更] チェックボックスの変更イベント (最大2枚選択) ▼▼▼
              checkbox.onchange = () => {
                  if (checkbox.checked) {
                      // --- チェックが入った時 ---
                      
                      // 1. 選択配列に追加
                      selectedFrames.push({ ...frame, checkbox: checkbox });
                      // 2. 選択数が3枚以上になったかチェック
                      if (selectedFrames.length > 2) {
                          // 3枚になったら、一番古い選択を解除
                          const oldestSelection = selectedFrames.shift();
                          if (oldestSelection && oldestSelection.checkbox) {
                              oldestSelection.checkbox.checked = false;
                          }
                      }
                      
                  } else {
                      // --- チェックが外れた時 ---
                      // 配列から該当フレームを削除
                      selectedFrames = selectedFrames.filter(sf => sf.timeSec !== frame.timeSec);
                  }
                  
                  // 3. 確定ボタンのUIを更新
                  updateConfirmButtonUI(selectedFrames.length);

                  // 4. ★★★【新規追加】★★★
                  //    選択範囲のハイライトを更新
                  updateRangeHighlighting();
              };
              // ▲▲▲ [変更] ここまで ▲▲▲

              // ▼▼▼ [変更] 拡大モーダルの「この画像を追加」ボタン処理 ▼▼▼
              img.onclick = () => {
                  enlargeImg.src = frame.data;
                  enlargeModal.style.display = 'flex';
                  
                  if (currentEnlargeSelectHandler) {
                      enlargeSelectBtn.removeEventListener('click', currentEnlargeSelectHandler);
                  }
                  
                  // 「この画像を追加」ボタンの処理
                  currentEnlargeSelectHandler = () => {
                      if (!checkbox.checked) { // まだチェックされていなければ
                          checkbox.checked = true;
                          // onchangeイベントを手動で発火させて、選択ロジックを共通化
                          checkbox.dispatchEvent(new Event('change'));
                      }
                      enlargeModal.style.display = 'none';
                  };
                  enlargeSelectBtn.addEventListener('click', currentEnlargeSelectHandler);
              };
              // ▲▲▲ [変更] ここまで ▲▲▲

              candidateDiv.appendChild(checkbox);
              candidateDiv.appendChild(img);
              gridContainer.appendChild(candidateDiv);
          });
          
          // --- 4b. 確定ボタンUI更新ヘルパー ---
          function updateConfirmButtonUI(count) {
              if (count === 0) {
                  confirmBtn.disabled = true;
                  confirmBtn.textContent = '画像を選択してください';
              } else if (count === 1) {
                  confirmBtn.disabled = false;
                  confirmBtn.textContent = '選択した画像を確定';
              } else if (count === 2) {
                  confirmBtn.disabled = false;
                  confirmBtn.textContent = '選択範囲をアニメーションに変換';
              }
          }

          function updateRangeHighlighting() {
              const allCandidates = gridContainer.querySelectorAll('.frame-candidate');

              // 1. 選択されているフレームのインデックスを取得
              const selectedIndices = selectedFrames.map(sf => {
                  // ID (frame-check-X) からインデックス (X) を抽出
                  return parseInt(sf.checkbox.id.replace('frame-check-', ''), 10);
              });

              // 2. まず、すべてのハイライトをクリア
              allCandidates.forEach(c => c.classList.remove('highlighted-range'));

              // 3. 2枚選択されている場合のみ処理
              if (selectedIndices.length === 2) {
                  const minIndex = Math.min(selectedIndices[0], selectedIndices[1]);
                  const maxIndex = Math.max(selectedIndices[0], selectedIndices[1]);

                  // 4. min と max の「間」のフレームにハイライトクラスを追加
                  for (let i = minIndex + 1; i < maxIndex; i++) {
                      if (allCandidates[i]) {
                          allCandidates[i].classList.add('highlighted-range');
                      }
                  }
              }
          }

          // 拡大モーダルを閉じる処理 (変更なし)
          const closeEnlargeModal = () => {
              enlargeModal.style.display = 'none';
              if (currentEnlargeSelectHandler) {
                  enlargeSelectBtn.removeEventListener('click', currentEnlargeSelectHandler);
                  currentEnlargeSelectHandler = null;
              }
          };
          enlargeCloseBtn.onclick = closeEnlargeModal;
          enlargeModal.onclick = (e) => {
              if (e.target === enlargeModal) closeEnlargeModal();
          };

          // --- 5. メインモーダルの確定/キャンセル処理 ---
          modal.style.display = 'flex';
          
          // ▼▼▼ [変更] 確定ボタンのハンドラ (resolveする内容を変更) ▼▼▼
          const confirmHandler = () => {
              cleanup();
              if (selectedFrames.length === 1) {
                  // 1枚選択時: 静止画
                  resolve({ 
                    type: 'static', 
                    data: selectedFrames[0].data 
                  });
              } else if (selectedFrames.length === 2) {
                  // 2枚選択時: アニメーション用のフレーム情報
                  resolve({ 
                    type: 'animation', 
                    frames: [selectedFrames[0], selectedFrames[1]] // 2つのフレーム情報 {timeSec, data} を渡す
                  });
              } else {
                  // (通常ここには来ないが)
                  reject(new Error('選択数が不正です。'));
              }
          };
          // ▲▲▲ [変更] ここまで ▲▲▲

          const cancelHandler = () => { cleanup(); reject(new Error('動画なしで続行')); };
          
          function cleanup() {
              confirmBtn.removeEventListener('click', confirmHandler);
              cancelBtn.removeEventListener('click', cancelHandler);
              if (document.body.contains(enlargeModal)) {
                  document.body.removeChild(enlargeModal);
              }
              const styleElement = document.getElementById('frame-selection-styles');
              if (styleElement) {
                  document.head.removeChild(styleElement);
              }
              
              modal.style.display = 'none';
              dropZone.style.border = originalDropZoneStyle.border || '';
              dropZone.style.cursor = originalDropZoneStyle.cursor || '';
              dropZone.style.padding = originalDropZoneStyle.padding || '';
              dropZone.innerHTML = ''; 
              dropZone.appendChild(preview); 
              dropZone.appendChild(dropZoneText);

              if (modalContent) {
                  modalContent.style.maxWidth = originalModalStyle.maxWidth || '';
                  modalContent.style.maxHeight = originalModalStyle.maxHeight || '';
              }

              cancelBtn.style.display = '';

              confirmBtn.textContent = '確定';
          }
          
          confirmBtn.addEventListener('click', confirmHandler);
          cancelBtn.addEventListener('click', cancelHandler);
      });
  }


      /**
 * ▼▼▼ [修正版] APNG生成をWeb Workerに委任 (CDN手動フェッチ + window=self シム追加) ▼▼▼
 * ライブラリをメインスレッドでfetchし、Workerコードと結合してBlobを作成します。
 * @param {Array<ArrayBuffer>} frames - フレームのピクセルデータ (ArrayBuffer) の配列
 * @param {Array<number>} delays - 各フレームのディレイ時間 (ms) の配列
 * @param {number} width - 画像の幅
 * @param {number} height - 画像の高さ
 * @returns {Promise<string>} APNGのBase64データURLを返すPromise
 */
// ===== ★新動画処理ロジック: APNGアセンブラ + バウンド付き並列ストリーミングエンコーダ =====
//  各フレームを独立PNGとしてWorkerプールで並列圧縮し、メインで手書きAPNGコンテナに結合。
//  ・同時に抱える生RGBAは「処理中のKフレームぶん」だけ(= メモリ激減・大きい動画でも落ちにくい)
//  ・各WorkerはUPNGに1枚だけ渡すので、UPNGの作業メモリ肥大(従来は100枚一括で1GB級)も消える
//  ・可逆(cnum=0)＝無劣化。出力は標準APNG(Chrome/プレゼン表示で再生可)。
const _APNG_CRC_TABLE = (function(){var t=new Uint32Array(256);for(var n=0;n<256;n++){var c=n;for(var k=0;k<8;k++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);t[n]=c>>>0;}return t;})();
function _apngCrc32(buf){var c=0xFFFFFFFF;for(var i=0;i<buf.length;i++)c=_APNG_CRC_TABLE[(c^buf[i])&255]^(c>>>8);return (c^0xFFFFFFFF)>>>0;}
function _apngU32(n){return new Uint8Array([(n>>>24)&255,(n>>>16)&255,(n>>>8)&255,n&255]);}
function _apngMakeChunk(type,data){var tb=new Uint8Array(4);for(var i=0;i<4;i++)tb[i]=type.charCodeAt(i);var crcIn=new Uint8Array(4+data.length);crcIn.set(tb,0);crcIn.set(data,4);var out=new Uint8Array(12+data.length);out.set(_apngU32(data.length),0);out.set(tb,4);out.set(data,8);out.set(_apngU32(_apngCrc32(crcIn)),8+data.length);return out;}
function _apngParsePNG(buf){var b=new Uint8Array(buf);var p=8;var ihdr=null;var idats=[];while(p<b.length){var len=((b[p]<<24)|(b[p+1]<<16)|(b[p+2]<<8)|b[p+3])>>>0;var type=String.fromCharCode(b[p+4],b[p+5],b[p+6],b[p+7]);var data=b.subarray(p+8,p+8+len);if(type==='IHDR')ihdr=data.slice();else if(type==='IDAT')idats.push(data.slice());p+=12+len;}var total=idats.reduce(function(a,d){return a+d.length;},0);var idat=new Uint8Array(total);var o=0;for(var j=0;j<idats.length;j++){idat.set(idats[j],o);o+=idats[j].length;}return {ihdr:ihdr,idat:idat};}
// ★APNG結合。regionsArr[i]={x,y,w,h}を渡すとフレーム間差分(apngopt相当)=各フレームを変化矩形だけのサブ画像にできる
//   (frame0=全画面IDAT、以降=fdATでサブ矩形を上書き／dispose=NONE:前フレーム保持・blend=SOURCE:上書き)。
//   regionsArr省略時は従来どおり全画面(x0y0 w×h)。可逆性はnode検証ハーネスで実証済(復元完全一致)。
function _assembleAPNG(pngs,w,h,delaysArr,regionsArr){
  var parsed=pngs.map(_apngParsePNG);
  // ★色タイプ整合チェック: 全フレームのbitDepth/colorType/interlaceがframe0と一致しないとfdATが壊れる
  //   (差分時にサブ画像がパレット化等で不一致になる事故を検出)→throwし呼び出し側のフォールバックへ回す。
  var ih0=parsed[0].ihdr;
  for(var ci=1;ci<parsed.length;ci++){var ih=parsed[ci].ihdr; if(!ih||ih[8]!==ih0[8]||ih[9]!==ih0[9]||ih[12]!==ih0[12]) throw new Error('[apng-assemble] frame'+ci+' 色タイプ不一致(bit/ctype/il='+(ih?ih[8]+'/'+ih[9]+'/'+ih[12]:'none')+' vs '+ih0[8]+'/'+ih0[9]+'/'+ih0[12]+')');}
  var parts=[new Uint8Array([137,80,78,71,13,10,26,10])];
  parts.push(_apngMakeChunk('IHDR',parsed[0].ihdr));
  var actl=new Uint8Array(8);actl.set(_apngU32(pngs.length),0);actl.set(_apngU32(0),4);parts.push(_apngMakeChunk('acTL',actl));
  var seq=0;
  for(var i=0;i<pngs.length;i++){
    var dms=(delaysArr && delaysArr[i]!=null)?delaysArr[i]:100; var dnum=Math.min(65535,Math.max(1,Math.round(dms))), dden=1000;
    var reg=(regionsArr && regionsArr[i])?regionsArr[i]:{x:0,y:0,w:w,h:h};
    var f=new Uint8Array(26),o=0;
    f.set(_apngU32(seq),o);o+=4;f.set(_apngU32(reg.w),o);o+=4;f.set(_apngU32(reg.h),o);o+=4;f.set(_apngU32(reg.x),o);o+=4;f.set(_apngU32(reg.y),o);o+=4;
    f[o++]=(dnum>>8)&255;f[o++]=dnum&255;f[o++]=(dden>>8)&255;f[o++]=dden&255;f[o++]=0;f[o++]=0;
    parts.push(_apngMakeChunk('fcTL',f));seq++;
    if(i===0){parts.push(_apngMakeChunk('IDAT',parsed[0].idat));}
    else{var d=parsed[i].idat;var fd=new Uint8Array(4+d.length);fd.set(_apngU32(seq),0);fd.set(d,4);parts.push(_apngMakeChunk('fdAT',fd));seq++;}
  }
  parts.push(_apngMakeChunk('IEND',new Uint8Array(0)));
  var total=parts.reduce(function(a,p){return a+p.length;},0);var out=new Uint8Array(total),oo=0;for(var k=0;k<parts.length;k++){out.set(parts[k],oo);oo+=parts[k].length;}
  return out.buffer;
}
// ★フレーム間差分: 前フレームと異なるピクセルの最小矩形(Uint32で1ピクセル1比較)。変化なし→null。
function _diffBBox(prev, cur, w, h) {
  var p32 = new Uint32Array(prev.buffer, prev.byteOffset, w*h);
  var c32 = new Uint32Array(cur.buffer, cur.byteOffset, w*h);
  var minX=w, minY=h, maxX=-1, maxY=-1;
  for (var y=0; y<h; y++){ var row=y*w; for (var x=0; x<w; x++){ if (p32[row+x]!==c32[row+x]){ if(x<minX)minX=x; if(x>maxX)maxX=x; if(y<minY)minY=y; if(y>maxY)maxY=y; } } }
  if (maxX<0) return null;
  return { x:minX, y:minY, w:maxX-minX+1, h:maxY-minY+1 };
}
// ★矩形(x,y,bw,bh)のRGBAを連続バッファに切り出す
function _extractSubRGBA(cur, w, x, y, bw, bh) {
  var out = new Uint8Array(bw*bh*4);
  for (var row=0; row<bh; row++){ var srcOff=((y+row)*w + x)*4; out.set(cur.subarray(srcOff, srcOff+bw*4), row*bw*4); }
  return out;
}
async function createAPNGFromFramesNew(frames, delays, width, height) {
    console.log('[新動画処理ロジック] 開始: ' + frames.length + 'フレーム ' + width + 'x' + height + ' / cores=' + (navigator.hardwareConcurrency || '?'));
    // ライブラリをメインスレッドで取得し、Workerブロブに inline(GAS sandboxedではimportScripts不可のため)
    const PACO_URL = 'https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js';
    const UPNG_URL = 'https://cdn.jsdelivr.net/npm/upng-js@2.1.0/UPNG.min.js';
    let pakoR, upngR;
    try {
      [pakoR, upngR] = await Promise.all([fetch(PACO_URL), fetch(UPNG_URL)]);
    } catch (fe) {
      throw new Error('[lib-fetch] CDN(jsdelivr)取得で例外。拡張機能/ネットワークでブロックされている可能性: ' + ((fe && fe.message) || fe));
    }
    if (!pakoR.ok || !upngR.ok) throw new Error('[lib-fetch] CDN取得失敗 HTTP pako=' + pakoR.status + ' upng=' + upngR.status);
    const pakoTxt = await pakoR.text(); const upngTxt = await upngR.text();
    const workerSrc = 'var window=self;\n' + pakoTxt + '\n' + upngTxt + '\n'
      + 'self.onmessage=function(e){var d=e.data;try{var png=UPNG.encode([d.buf],d.w,d.h,0,[]);self.postMessage({idx:d.idx,png:png},[png]);}catch(err){self.postMessage({idx:d.idx,error:(err&&err.message)||String(err)});}};';
    const blobUrl = URL.createObjectURL(new Blob([workerSrc], { type: 'application/javascript' }));
    const N = frames.length;
    const K = Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4)));
    const compressed = new Array(N);
    try {
      await new Promise((resolve, reject) => {
        let nextIdx = 0, done = 0, finished = false;
        const workers = [];
        let watchdog = null;
        const cleanup = () => { if (watchdog) { clearTimeout(watchdog); watchdog = null; } workers.forEach(w => { try { w.terminate(); } catch (e) {} }); };
        const finish = (err) => { if (finished) return; finished = true; cleanup(); err ? reject(err) : resolve(); };
        watchdog = setTimeout(() => finish(new Error('[encode-timeout] ' + done + '/' + N + 'フレーム完了で120秒タイムアウト(Worker停止/throttling/ブロックの可能性)')), 120000);
        const dispatch = (wk) => {
          if (finished || nextIdx >= N) return;
          const idx = nextIdx++;
          const buf = frames[idx];
          frames[idx] = null; // ★参照を即解放(配列がエンコード進行に伴い空く)
          if (!buf) { done++; (done === N) ? finish() : dispatch(wk); return; }
          wk.postMessage({ idx: idx, buf: buf, w: width, h: height }, [buf]); // 転送=mainから即解放
        };
        for (let k = 0; k < Math.min(K, N); k++) {
          const wk = new Worker(blobUrl);
          wk.onmessage = function (ev) {
            if (ev.data.error) { finish(new Error('APNG新方式 worker: ' + ev.data.error)); return; }
            compressed[ev.data.idx] = ev.data.png; // 圧縮済み(小)だけ保持
            done++;
            (done === N) ? finish() : dispatch(this);
          };
          wk.onerror = function (er) { finish(new Error('APNG新方式 worker error: ' + (er.message || ''))); };
          workers.push(wk);
        }
        workers.forEach(wk => dispatch(wk)); // 各Workerに1枚ずつ投入して開始
      });
    } finally {
      URL.revokeObjectURL(blobUrl);
    }
    // 欠損(あれば)を除いて順序通りに結合
    let apngBuf;
    try {
      const order = [];
      for (let i = 0; i < N; i++) { if (compressed[i]) order.push(i); }
      if (order.length === 0) throw new Error('全フレームのエンコードに失敗(有効0枚)');
      console.log('[新動画処理ロジック] 圧縮完了 ' + order.length + '/' + N + '枚 → APNG結合');
      apngBuf = _assembleAPNG(order.map(i => compressed[i]), width, height, order.map(i => (delays && delays[i] != null) ? delays[i] : 100));
    } catch (ae) {
      throw new Error('[assemble] APNG結合で例外: ' + ((ae && ae.message) || ae));
    }
    // Base64データURLに変換(従来の createAPNGFromFrames と同じ返り値形式)
    try {
      const result = await new Promise((resolve, reject) => {
        const fr = new FileReader();
        fr.onload = () => resolve(fr.result);
        fr.onerror = () => reject(new Error('FileReader失敗'));
        fr.readAsDataURL(new Blob([apngBuf], { type: 'image/png' }));
      });
      console.log('[新動画処理ロジック] 完了: APNG ' + Math.round(apngBuf.byteLength / 1024) + 'KB');
      return result;
    } catch (be) {
      throw new Error('[base64] ' + ((be && be.message) || be));
    }
}

// ★新動画処理ロジック: 抽出と圧縮をストリーミング(チャンク)化＝生フレームを一括で持たない(OOM根本対策)
//  動画seekは1本のvideo要素で逐次しか出来ないため、Kフレームずつ seek→getImageData→並列圧縮→破棄 を繰り返す。
//  同時に抱える生RGBAは常にKフレーム(~8枚=約30MB)だけ。100コマ・720pでもメモリは一定。可逆無劣化。
// ===== ★Method A: 動画アニメ(APNG)を Google Drive へ直接アップロード→参照(巨大JSONを作らない) =====
//  dataURL → Blob
function _dataURLtoBlob(dataURL) {
  const comma = dataURL.indexOf(',');
  const header = dataURL.slice(0, comma);
  const isB64 = /;base64/i.test(header);
  const mime = (header.match(/data:([^;,]+)/) || [null, 'application/octet-stream'])[1];
  const dataPart = dataURL.slice(comma + 1);
  if (isB64) {
    const bin = atob(dataPart);
    const len = bin.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
    return new Blob([bytes], { type: mime });
  }
  return new Blob([decodeURIComponent(dataPart)], { type: mime });
}
//  GAS経由で Drive アップロード用 OAuthトークン取得
function _getDriveUploadToken() {
  return new Promise((resolve, reject) => {
    google.script.run
      .withSuccessHandler(r => resolve(r))
      .withFailureHandler(e => reject(new Error('トークン取得失敗: ' + (e && e.message ? e.message : e))))
      .getDriveUploadToken();
  });
}
//  Blob を Drive へ直接アップロード(multipart, CORS対応の googleapis.com)→ ファイルID
async function _uploadBlobToDrive(blob, filename, token) {
  const form = new FormData();
  form.append('metadata', new Blob([JSON.stringify({ name: filename })], { type: 'application/json' }));
  form.append('file', blob);
  const resp = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer ' + token },
    body: form
  });
  if (!resp.ok) { let t = ''; try { t = await resp.text(); } catch (e) {} throw new Error('Driveアップロード失敗 HTTP ' + resp.status + ' ' + t.slice(0, 200)); }
  const result = await resp.json();
  if (!result || !result.id) throw new Error('DriveファイルID取得失敗');
  return result.id;
}
//  ★一時ファイルをアップロード直後に「ゴミ箱」へ(マイドライブを汚さない＋30日自動削除の安全網を処理前に張る)。
//   ゴミ箱内でもファイルIDで読めるためバックエンドの挿入に支障なし。最終的な完全削除はバックエンドのfinally。
async function _trashDriveFileNow(fileId, token) {
  const resp = await fetch('https://www.googleapis.com/drive/v3/files/' + fileId + '?fields=id', {
    method: 'PATCH',
    headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
    body: JSON.stringify({ trashed: true })
  });
  if (!resp.ok) { let t = ''; try { t = await resp.text(); } catch (e) {} throw new Error('ゴミ箱化失敗 HTTP ' + resp.status + ' ' + t.slice(0, 150)); }
}

// ★[新規Q] HTTP 413回避: splitImage の base64画像(背景/レイヤー)をDriveへ先行アップロードし 'DRIVE_IMAGE:'+fileId 参照へ置換する。
//   巨大base64をJSONに載せずサーバへ送れる。サーバ側 _resolveDriveImagesInSlideData が描画時にfileIdへ解決(既存機構, splitImageネスト対応済)。
//   slideData を破壊的に書き換える。重複base64は同一refを再利用。
async function _offloadSplitImagesToDrive(slideData) {
  if (!Array.isArray(slideData)) return slideData;
  const cache = new Map(); // base64 -> 'DRIVE_IMAGE:'+fileId
  let tk = null;
  const put = async (b64) => {
    if (typeof b64 !== 'string' || b64.indexOf('data:image/') !== 0) return b64; // 既に参照/URL/fileIdならそのまま
    if (cache.has(b64)) return cache.get(b64);
    if (!tk) tk = await _getDriveUploadToken();
    const blob = _dataURLtoBlob(b64);
    const fileId = await _uploadBlobToDrive(blob, 'majin_split_' + Date.now() + '_' + cache.size + '.png', tk.token);
    // ★[残骸対策] アップロード直後に即ゴミ箱へ(マイドライブを汚さない＋30日自動削除の安全網)。
    //   ゴミ箱内でもfileIdで読めるためサーバ描画に支障なし。最終的な完全削除はサーバのfinally(_trashDriveTempFiles)。
    //   アニメ経路(_trashDriveFileNow @ majin_anim)と挙動を揃える。送信失敗/タブクローズ等でサーバfinallyが
    //   走らない場合でも、My Driveルートに majin_split_*.png が残らないようにする。
    try { await _trashDriveFileNow(fileId, tk.token); }
    catch (te) { console.warn('[Method A][split] 即ゴミ箱化に失敗(サーバfinallyで削除されます): ' + (te && te.message)); }
    const ref = 'DRIVE_IMAGE:' + fileId;
    cache.set(b64, ref);
    return ref;
  };
  for (const slide of slideData) {
    if (!slide || slide.type !== 'splitImage' || !slide.image || typeof slide.image !== 'object') continue;
    const img = slide.image;
    // 送信不要で容量の主因となる重複フィールドを除去(描画には background/layers のみ使用)。
    try { delete img.data; } catch (e) {}
    try { delete img.originalData; } catch (e) {}
    try { delete img.ocrCache; } catch (e) {}
    if (img.backgroundImage) img.backgroundImage = await put(img.backgroundImage);
    if (Array.isArray(img.layers)) {
      for (const l of img.layers) {
        if (l && l.type !== 'text') {
          try { if (l.dataBase64) delete l.dataBase64; } catch (e) {} // key と重複するため除去
          if (l.key) l.key = await put(l.key);
        }
      }
    }
  }
  return slideData;
}

const _SLIDES_IMG_LIMIT = 49 * 1048576; // Slidesの画像50MB上限に対する安全マージン(1要素あたりの上限)
const _TOTAL_ANIM_BUDGET = 90 * 1048576; // 1スライドデッキ内の動画アニメ要素の合計サイズ上限(全体で90MB以下に収める)
// ★複数アニメは「実使用を差し引いて残り本数で再分配」する動的配分(extractFrames生成フェーズ)。各本の上限=min(49MB, 残り予算/残り本数)。

// ───────── APNG自動フィット(可逆維持・解像度優先で50MB以下に収める) ─────────
// pako+UPNGのワーカーソースは一度だけ取得してキャッシュ(複数パスで再fetchしない)。
let _apngWorkerSrcCache = null;
async function _getAPNGWorkerSrc() {
  if (_apngWorkerSrcCache) return _apngWorkerSrcCache;
  const PACO_URL = 'https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js';
  const UPNG_URL = 'https://cdn.jsdelivr.net/npm/upng-js@2.1.0/UPNG.min.js';
  let pakoR, upngR;
  try { [pakoR, upngR] = await Promise.all([fetch(PACO_URL), fetch(UPNG_URL)]); }
  catch (fe) { throw new Error('[lib-fetch] CDN(jsdelivr)取得で例外。拡張機能/ネットワークでブロックの可能性: ' + ((fe && fe.message) || fe)); }
  if (!pakoR.ok || !upngR.ok) throw new Error('[lib-fetch] CDN取得失敗 HTTP pako=' + pakoR.status + ' upng=' + upngR.status);
  const pakoTxt = await pakoR.text(); const upngTxt = await upngR.text();
  // ★UPNG.encode(...,0,[],true) の第6引数 forbidPlte=true: フレーム間差分でサブ画像が小さい時に
  //   パレットPNG化して色タイプがframe0(トゥルーカラー)と不一致になりfdATが壊れるのを防ぐ(全フレーム同一色タイプを強制)。
  _apngWorkerSrcCache = 'var window=self;\n' + pakoTxt + '\n' + upngTxt + '\n'
    + 'self.onmessage=function(e){var d=e.data;try{var png=UPNG.encode([d.buf],d.w,d.h,0,[],true);self.postMessage({idx:d.idx,png:png},[png]);}catch(err){self.postMessage({idx:d.idx,error:(err&&err.message)||String(err)});}};';
  return _apngWorkerSrcCache;
}
// 等間隔のフレーム時刻配列を作る
function _frameTimes(startSec, endSec, frameCount) {
  const dur = endSec - startSec; const times = [];
  for (let i = 0; i < frameCount; i++) { const p = (frameCount <= 1) ? 0 : (i / (frameCount - 1)); times.push(startSec + dur * p); }
  return times;
}
// 指定解像度(w,h)・指定時刻配列で 可逆APNG Blob を生成(同時保持の生RGBAはK枚だけ＝メモリ一定)
// ★Workerプール(バックプレッシャ付き): dispatch()は空きWorkerを待ってから送信し {done:完了Promise} を返す。
//   呼び出し側は dispatch を await(=空き待ち=背圧)してから次のseekへ進むので、シーク(I/O)とWorker圧縮(CPU)が重なり、
//   バッチ境界の待ち合わせ(バリア)が消える=高速化。同時実行は最大K=同時に抱えるフレームもK枚=メモリ一定。
function _makeWorkerPool(blobUrl, K) {
  const workers = []; for (let k = 0; k < K; k++) workers.push(new Worker(blobUrl));
  const idle = workers.slice(); const waiters = [];
  const acquire = () => idle.length ? Promise.resolve(idle.pop()) : new Promise(r => waiters.push(r));
  const release = (wk) => { const w = waiters.shift(); if (w) w(wk); else idle.push(wk); };
  async function dispatch(payload, transfer) {
    const wk = await acquire(); // 空きWorkerを待つ=バックプレッシャ(同時実行<=K)
    const done = new Promise((resolve, reject) => {
      const onmsg = (ev) => { wk.removeEventListener('message', onmsg); wk.removeEventListener('error', onerr); release(wk); if (ev.data && ev.data.error) reject(new Error(ev.data.error)); else resolve(ev.data); };
      const onerr = (er) => { wk.removeEventListener('message', onmsg); wk.removeEventListener('error', onerr); release(wk); reject(new Error('worker error: ' + (er.message || ''))); };
      wk.addEventListener('message', onmsg); wk.addEventListener('error', onerr);
    });
    wk.postMessage(payload, transfer || []);
    return { done: done }; // doneは「完了Promise」。await dispatch自体は送信完了で解決(完了は待たない)。
  }
  return { dispatch: dispatch, terminate: () => workers.forEach(w => { try { w.terminate(); } catch (e) {} }) };
}
async function _encodeFramesToAPNGBlob(video, animCanvas, w, h, times, delayMs) {
  const workerSrc = await _getAPNGWorkerSrc();
  const blobUrl = URL.createObjectURL(new Blob([workerSrc], { type: 'application/javascript' }));
  animCanvas.width = w; animCanvas.height = h;
  const ctx = animCanvas.getContext('2d', { willReadFrequently: true });
  const n = times.length;
  const K = Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4)));
  const compressed = new Array(n);
  const regions = new Array(n);     // ★フレーム間差分: 各フレームの矩形{x,y,w,h}(0番=全画面)
  let prevFull = null;              // 直前に成功抽出したフレームの全RGBA(差分の基準。転送しないので保持され続ける)
  // ★パイプライン: seek→draw→diff→dispatch を逐次で回しつつ、圧縮はWorkerプールで並行。dispatchのawait(空き待ち)が背圧。
  const pool = _makeWorkerPool(blobUrl, Math.max(1, Math.min(K, n)));
  const pending = [];
  try {
    for (let i = 0; i < n; i++) {
      try { await seekToTime(video, times[i], 15000); } catch (se) { continue; } // seek失敗frameはskip(prevFull据置=順序内の直前成功フレームと差分連鎖)
      ctx.drawImage(video, 0, 0, w, h);
      const cur = ctx.getImageData(0, 0, w, h).data; // Uint8ClampedArray(w*h*4)
      // ★差分矩形: 最初の成功フレーム=全画面、以降=前フレームとの差分bbox(無変化なら1x1に縮退)
      const reg = (prevFull === null) ? { x: 0, y: 0, w: w, h: h } : (_diffBBox(prevFull, cur, w, h) || { x: 0, y: 0, w: 1, h: 1 });
      const sub = _extractSubRGBA(cur, w, reg.x, reg.y, reg.w, reg.h); // 矩形のみ抽出(全画面時は全コピー)
      regions[i] = reg;
      prevFull = cur; // 次フレームの差分基準として保持(subは別バッファ=転送してもprevFullは無傷)
      const handle = await pool.dispatch({ idx: i, buf: sub.buffer, w: reg.w, h: reg.h }, [sub.buffer]); // 空きWorker確保まで待機(背圧)
      pending.push(handle.done.then(d => { compressed[d.idx] = d.png; }));
    }
    await Promise.all(pending);
  } finally {
    pool.terminate();
    URL.revokeObjectURL(blobUrl);
  }
  const order = [];
  for (let k = 0; k < n; k++) { if (compressed[k]) order.push(k); }
  if (order.length === 0) throw new Error('[APNG] 全フレームの抽出/圧縮に失敗(0枚)');
  // ★差分結合: 先頭フレームの矩形を全画面に正規化(skip等で先頭が差分にならないよう保険)。色タイプ不一致時は_assembleAPNGがthrow→上位フォールバック。
  const ordRegions = order.map(k => regions[k]);
  ordRegions[0] = { x: 0, y: 0, w: w, h: h };
  // ★差分被覆率: 2枚目以降の差分矩形が平均でキャンバスの何割を占めるか(小さいほどフレーム間差分が効いている=可逆で軽量化)
  let cov = 0, covN = 0;
  for (let k = 1; k < order.length; k++) { const rg = regions[order[k]]; cov += (rg.w * rg.h) / (w * h); covN++; }
  const avgCoverage = covN ? (cov / covN) : 1;
  // ★フレーム別圧縮サイズ(差分対応プローブの推定に使用: sizes[0]=全画面・sizes[1..]=差分)
  const sizes = order.map(k => (compressed[k] && (compressed[k].byteLength != null ? compressed[k].byteLength : compressed[k].length)) || 0);
  const apngBuf = _assembleAPNG(order.map(k => compressed[k]), w, h, order.map(() => delayMs), ordRegions);
  return { blob: new Blob([apngBuf], { type: 'image/png' }), frames: order.length, avgCoverage: avgCoverage, sizes: sizes };
}
// 可逆APNGのまま 50MB以下に自動フィット。色もフレームも触らず(=無劣化)、フレーム間差分の効果を織り込んで
// 「原解像度を上限に、上限サイズへ収まる最大解像度」を選ぶ(=同じサイズ枠でできるだけ高解像度)。
// 下限解像度でも収まらない時だけフレーム数を減らす。差分対応プローブで巨大APNGを作らずサイズ推定する。
async function _fitAPNGUnderLimit(video, animCanvas, startSec, endSec, frameCount, delayMs, limit) {
  const origW = animCanvas.width, origH = animCanvas.height;
  const aspect = origW / origH;
  const origShort = Math.min(origW, origH);
  const MIN_SHORT = 320, MIN_FRAMES = 20;
  const TARGET = limit * 0.90;        // 目標サイズ(安全マージン)。これに近い最大解像度を狙う。
  const dimsFor = function (shortSide) { // 短辺→偶数の(w,h)。原解像度を超えない。
    let s = Math.min(origShort, Math.max(MIN_SHORT, Math.round(shortSide)));
    let w = (aspect >= 1) ? Math.round(s * aspect) : s;
    let h = (aspect >= 1) ? s : Math.round(s / aspect);
    w = Math.min(origW, w - (w % 2)); h = Math.min(origH, h - (h % 2));
    return { w: w, h: h, short: Math.min(w, h) };
  };
  // 1) 差分対応プローブ: 原解像度で「先頭の連続フレーム」を実エンコードし、全画面(full0)＋隣接差分(avgDiff)の
  //    実サイズから全クリップを推定(離間フレームだと差分を過大評価し低解像度になるため、実隣接間隔で測る)。
  let estFullOrig = null;
  try {
    const probeN = Math.min(8, frameCount);
    const probeTimes = _frameTimes(startSec, endSec, frameCount).slice(0, probeN); // 実タイムラインの先頭=実隣接間隔
    const pr = await _encodeFramesToAPNGBlob(video, animCanvas, origW, origH, probeTimes, delayMs);
    const sizes = pr.sizes || [];
    const full0 = (sizes[0] != null && sizes[0] > 0) ? sizes[0] : (pr.blob.size / Math.max(1, pr.frames));
    let diffSum = 0, diffN = 0;
    for (let k = 1; k < sizes.length; k++) { diffSum += sizes[k]; diffN++; }
    const avgDiff = diffN ? (diffSum / diffN) : full0;
    estFullOrig = full0 + (frameCount - 1) * avgDiff;
    console.log('[Method A] APNGプローブ(差分) ' + origW + 'x' + origH + ' full0=' + (full0 / 1048576).toFixed(2) + 'MB avgDiff=' + Math.round(avgDiff / 1024) + 'KB(' + diffN + '枚) → 全' + frameCount + 'f推定 ' + (estFullOrig / 1048576).toFixed(1) + 'MB');
  } catch (pe) { console.warn('[Method A] APNGプローブ失敗(原解像度で直接生成へ): ' + (pe && pe.message)); }
  // 2) 初期解像度: 推定が目標以下なら原解像度(最高画質)。超なら面積比(size∝面積)で目標に収まる短辺へ。
  let curW = origW, curH = origH, curFrames = frameCount;
  if (estFullOrig && estFullOrig > TARGET) {
    const factor = TARGET / estFullOrig;
    let newShort = Math.max(MIN_SHORT, Math.round(origShort * Math.sqrt(factor)));
    const areaApplied = (newShort * newShort) / (origShort * origShort);
    if (areaApplied > factor) curFrames = Math.max(MIN_FRAMES, Math.round(frameCount * (factor / areaApplied))); // 下限でも超→フレーム削減
    const d = dimsFor(newShort); curW = d.w; curH = d.h;
  }
  // 3) 本エンコード + 双方向補正(超過→縮小 / 余裕大→原解像度を上限に拡大。最大6回)。最大解像度の採用結果(best)を保持。
  let best = null; // {blob, meta, w, h, frames, area}
  for (let attempt = 0; attempt < 6; attempt++) {
    const r = await _encodeFramesToAPNGBlob(video, animCanvas, curW, curH, _frameTimes(startSec, endSec, curFrames), delayMs);
    const mb = (r.blob.size / 1048576).toFixed(1);
    console.log('[Method A] APNG試行' + attempt + ' ' + curW + 'x' + curH + ' ' + r.frames + 'f → ' + mb + 'MB (フレーム間差分: 平均被覆' + Math.round((r.avgCoverage != null ? r.avgCoverage : 1) * 100) + '%)');
    const area = curW * curH;
    if (r.blob.size <= limit) {
      if (!best || area > best.area) best = { blob: r.blob, meta: curW + 'x' + curH + ' ' + r.frames + 'f ' + mb + 'MB', w: curW, h: curH, frames: r.frames, area: area };
      const curShort = Math.min(curW, curH);
      // 余裕が大きく、まだ原解像度未満なら拡大して同サイズ枠で高解像度化(原解像度が上限=アップスケールしない)
      if (r.blob.size < TARGET * 0.92 && curShort < origShort) {
        const grow = Math.sqrt(TARGET / r.blob.size);
        const d = dimsFor(curShort * grow);
        if (d.short <= curShort) break; // これ以上拡大できない → 採用
        curW = d.w; curH = d.h; continue;
      }
      break; // 採用(目標近傍 or 原解像度)
    }
    // 超過 → 縮小(下限ならフレーム削減)
    const overFactor = TARGET / r.blob.size;
    const curShort = Math.min(curW, curH);
    if (curShort <= MIN_SHORT) {
      if (curFrames <= MIN_FRAMES) break;               // これ以上詰められない → GIFへ
      curFrames = Math.max(MIN_FRAMES, Math.round(curFrames * overFactor));
    } else {
      let newShort = Math.max(MIN_SHORT, Math.round(curShort * Math.sqrt(overFactor)));
      if (newShort >= curShort) newShort = Math.max(MIN_SHORT, curShort - 40); // 必ず縮める
      const d = dimsFor(newShort); curW = d.w; curH = d.h;
    }
  }
  animCanvas.width = origW; animCanvas.height = origH; // 呼び出し側のcanvas状態を復元
  if (best) return { blob: best.blob, meta: best.meta, w: best.w, h: best.h, frames: best.frames };
  return null; // 下限設定でも50MB超(極端な動画)
}

//  アニメを生成→Drive直アップロード→参照(DRIVE_IMAGE:<id>)を返す。巨大base64をJSONに載せない。
//  方針: APNG(可逆・最高画質)を解像度優先で50MB以下に自動フィットして採用(色/フレームは極力維持)。
//        下限設定でも収まらない極端な動画のみGIF(256色)へ。いずれも“Drive経由”で渡す(google.script.runの~50MB制限も回避)。
async function createAnimationToDrive(video, animCanvas, startSec, endSec, frameCount, delayMs, limit) {
  let blob = null, ext = 'png', kind = '';
  // limit=このアニメ1本に許される上限バイト数(複数アニメ時は合計90MB以下になるよう配分済)。未指定は1要素上限。
  if (limit == null) limit = _SLIDES_IMG_LIMIT;
  const limitMB = (limit / 1048576).toFixed(1);
  // 出力形式(設定 #animationFormat): 'gif'=GIF直行(256色・小サイズ・Slidesで確実に再生) / それ以外='apng'(可逆・最高画質を解像度優先で上限以下に自動収束し、収まらない時のみGIF)
  // ★[新1] 出力形式は「動画モーダルの種類選択(#videoAnimFormatSelect)」を最優先。無ければ開発者設定(#animationFormat)。既定apng。
  const __fmt = (typeof $ === 'function' && $('videoAnimFormatSelect') && $('videoAnimFormatSelect').value === 'gif') ? 'gif'
    : ((typeof $ === 'function' && $('animationFormat') && $('animationFormat').value === 'gif') ? 'gif' : 'apng');
  // 1) APNG(可逆)を上限以下に自動フィット(解像度優先→必要時のみフレーム削減。色は無劣化のまま)。形式=apngのときのみ。
  if (__fmt === 'apng') {
    try {
      if (typeof showStatus === 'function') showStatus('アニメーションを最適化中...', 'info', 60000);
      const fit = await _fitAPNGUnderLimit(video, animCanvas, startSec, endSec, frameCount, delayMs, limit);
      if (fit && fit.blob) {
        blob = fit.blob; ext = 'png'; kind = 'APNG ' + fit.meta;
        console.log('[Method A] APNG確定 ' + fit.meta + ' (可逆・色無劣化)');
      } else {
        console.log('[Method A] APNGは下限設定でも' + limitMB + 'MB超 → GIFへ(最終手段)');
      }
    } catch (e) { console.warn('[Method A] APNG自動フィット失敗 → GIFへ: ' + (e && e.message)); }
  } else {
    console.log('[Method A] 出力形式=GIF(設定) → APNGをスキップしてGIFを生成');
  }
  // 2) GIF(形式=gifで選択時、またはapngが下限でも上限超/失敗時の最終手段)
  //    並列量子化＋解像度オートフィットで生成。失敗時のみ従来の単一スレッドGIFにフォールバック。
  if (!blob) {
    let gfit = null;
    try {
      if (typeof showStatus === 'function') showStatus('アニメーションを最適化中...', 'info', 60000);
      gfit = await _fitGIFUnderLimit(video, animCanvas, startSec, endSec, frameCount, delayMs, limit);
    } catch (ge) { console.warn('[Method A] GIF並列フィット失敗→従来GIFへ: ' + (ge && ge.message)); }
    if (gfit && gfit.blob) {
      blob = gfit.blob; ext = 'gif'; kind = 'GIF ' + gfit.meta;
      console.log('[Method A] GIF確定 ' + gfit.meta + ' (並列・オートフィット)');
    } else {
      // フォールバック: 従来の単一スレッドGIF(原解像度)
      const gifDataUrl = await streamingExtractEncodeGIF(video, animCanvas, startSec, endSec, frameCount, delayMs);
      const b = _dataURLtoBlob(gifDataUrl);
      const mb = (b.size / 1048576).toFixed(1);
      console.log('[Method A] GIF生成完了(従来フォールバック) ' + mb + 'MB');
      if (b.size > limit) {
        throw new Error('GIFも' + mb + 'MBで上限' + limitMB + 'MBを超えます。クリップを短く、または短辺をさらに下げてください。');
      }
      blob = b; ext = 'gif'; kind = 'GIF ' + mb + 'MB';
    }
  }
  // 3) Drive直アップロード(googleapis.com=CORS対応)
  console.log('[Method A] ' + kind + ' をGoogleドライブへアップロード');
  if (typeof showStatus === 'function') showStatus('アニメ(' + kind + ')をGoogleドライブにアップロード中...', 'info', 30000);
  const tk = await _getDriveUploadToken();
  const fileId = await _uploadBlobToDrive(blob, 'majin_anim_' + Date.now() + '.' + ext, tk.token);
  console.log('[Method A] Driveアップロード完了 fileId=' + fileId + ' (' + kind + ')');
  // ★アップロード直後に即ゴミ箱へ(マイドライブを汚さない＋30日自動削除の安全網を「処理前」に張る)。
  //   失敗しても致命的でない(バックエンドのfinallyが最終的に完全削除する)。ゴミ箱内でもIDで読めるので挿入に支障なし。
  try { await _trashDriveFileNow(fileId, tk.token); console.log('[Method A] 一時ファイルを即ゴミ箱へ(安全網) fileId=' + fileId); }
  catch (te) { console.warn('[Method A] 即ゴミ箱化に失敗(バックエンドfinallyで削除されます): ' + (te && te.message)); }
  // ★動的配分用に実サイズも返す(呼び出し側が残り予算から差し引く)。ref=slideDataに載せる参照文字列。
  return { ref: 'DRIVE_IMAGE:' + fileId, size: blob.size };
}

// ───────── GIF(並列・オートフィット) ─────────
// gifencライブラリ本文を一度だけ取得してキャッシュ(メイン用ロード/Workerソースの両方で再利用=再fetchしない)。
let _gifLibTextCache = null;
async function _getGifLibText() {
  if (_gifLibTextCache) return _gifLibTextCache;
  const GIFENC_URL = 'https://cdn.jsdelivr.net/npm/gifenc@1.0.3/dist/gifenc.js';
  try { const r = await fetch(GIFENC_URL); if (!r.ok) throw new Error('HTTP ' + r.status); _gifLibTextCache = await r.text(); }
  catch (fe) { throw new Error('[lib-fetch] gifenc取得失敗(拡張/ネットワーク遮断の可能性): ' + ((fe && fe.message) || fe)); }
  return _gifLibTextCache;
}
// メインスレッド用 gifenc(GIFEncoder=フレーム書き込み/組み立てに使用)。
let _gifMainCache = null;
async function _getGifMain() {
  if (_gifMainCache) return _gifMainCache;
  const src = await _getGifLibText();
  let G; try { const mod = { exports: {} }; (new Function('exports', 'module', src))(mod.exports, mod); G = mod.exports; }
  catch (le) { throw new Error('[gifenc-load] ' + ((le && le.message) || le)); }
  if (!G.GIFEncoder || !G.quantize || !G.applyPalette) throw new Error('[gifenc-load] API未取得: ' + Object.keys(G || {}).join(','));
  _gifMainCache = G; return G;
}
// Worker用ソース(重い量子化quantize+パレット適用applyPaletteを並列化)。CJSなのでmodule/exportsを与えて読み込む。
let _gifWorkerSrcCache = null;
async function _getGifWorkerSrc() {
  if (_gifWorkerSrcCache) return _gifWorkerSrcCache;
  const src = await _getGifLibText();
  _gifWorkerSrcCache = 'var module={exports:{}},exports=module.exports;\n' + src + '\n'
    + 'var G=module.exports;var quantize=G.quantize,applyPalette=G.applyPalette;\n'
    + 'self.onmessage=function(e){var d=e.data;try{var rgba=new Uint8Array(d.buf);var pal=quantize(rgba,256);var idx=applyPalette(rgba,pal);self.postMessage({idx:d.idx,index:idx.buffer,palette:pal},[idx.buffer]);}catch(err){self.postMessage({idx:d.idx,error:(err&&err.message)||String(err)});}};';
  return _gifWorkerSrcCache;
}
// ★GIFを並列生成: 量子化/パレット適用をWorkerプールで並行(全コア活用・UIフリーズ解消)、writeFrameは順序ストリーミング(メモリ一定)。
//   seek(I/O)とWorker(CPU)はパイプラインで重なる。skipフレームは順序を詰めて飛ばす。返り値 {blob, frames}。
async function _encodeFramesToGIFBlob(video, animCanvas, w, h, times, delayMs) {
  const G = await _getGifMain();
  const workerSrc = await _getGifWorkerSrc();
  const blobUrl = URL.createObjectURL(new Blob([workerSrc], { type: 'application/javascript' }));
  animCanvas.width = w; animCanvas.height = h;
  const ctx = animCanvas.getContext('2d', { willReadFrequently: true });
  const n = times.length;
  const K = Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4)));
  const gif = G.GIFEncoder();
  const slots = new Array(n);       // undefined=未完 / 'skip'=スキップ / {index,palette}=完了
  let nextWrite = 0, written = 0;
  const flush = () => { // 「次に書く番号」から順に、完了フレームをwriteFrame→解放(順序保証・メモリ一定)
    while (nextWrite < n && slots[nextWrite] !== undefined) {
      const s = slots[nextWrite];
      if (s && s !== 'skip') { gif.writeFrame(new Uint8Array(s.index), w, h, { palette: s.palette, delay: delayMs }); written++; }
      slots[nextWrite] = null; nextWrite++;
    }
  };
  const pool = _makeWorkerPool(blobUrl, Math.max(1, Math.min(K, n)));
  const pending = [];
  try {
    for (let i = 0; i < n; i++) {
      try { await seekToTime(video, times[i], 15000); } catch (se) { slots[i] = 'skip'; flush(); continue; }
      ctx.drawImage(video, 0, 0, w, h);
      const rgba = ctx.getImageData(0, 0, w, h).data; // 生RGBAは転送で即解放(同時保持は最大K枚)
      const handle = await pool.dispatch({ idx: i, buf: rgba.buffer, w: w, h: h }, [rgba.buffer]); // 空きWorker待ち=背圧
      pending.push(handle.done.then(d => { slots[d.idx] = { index: d.index, palette: d.palette }; flush(); }));
    }
    await Promise.all(pending);
    flush();
  } finally {
    pool.terminate();
    URL.revokeObjectURL(blobUrl);
  }
  if (written === 0) throw new Error('[gif] 全フレームの抽出/量子化に失敗(0枚)');
  gif.finish();
  const bytes = gif.bytes();
  return { blob: new Blob([bytes], { type: 'image/gif' }), frames: written };
}
// ★GIFも解像度オートフィット: APNGと同様に「原解像度を上限に、上限サイズ以下の最大解像度」を選ぶ(50MB超で失敗せず自動縮小/余裕あれば拡大)。
//   GIFはフレーム間差分が無いのでサイズは概ね 面積×フレーム数に比例(均一)。プローブ1枚平均×Nで推定。
async function _fitGIFUnderLimit(video, animCanvas, startSec, endSec, frameCount, delayMs, limit) {
  const origW = animCanvas.width, origH = animCanvas.height;
  const aspect = origW / origH;
  const origShort = Math.min(origW, origH);
  const MIN_SHORT = 320, MIN_FRAMES = 20;
  const TARGET = limit * 0.90;
  const dimsFor = function (shortSide) {
    let s = Math.min(origShort, Math.max(MIN_SHORT, Math.round(shortSide)));
    let w = (aspect >= 1) ? Math.round(s * aspect) : s;
    let h = (aspect >= 1) ? s : Math.round(s / aspect);
    w = Math.min(origW, w - (w % 2)); h = Math.min(origH, h - (h % 2));
    return { w: w, h: h, short: Math.min(w, h) };
  };
  let estFullOrig = null;
  try {
    const probeN = Math.min(6, frameCount);
    const probeTimes = _frameTimes(startSec, endSec, frameCount).slice(0, probeN);
    const pr = await _encodeFramesToGIFBlob(video, animCanvas, origW, origH, probeTimes, delayMs);
    const perFrame = pr.blob.size / Math.max(1, pr.frames);
    estFullOrig = perFrame * frameCount;
    console.log('[Method A] GIFプローブ ' + origW + 'x' + origH + ' ' + pr.frames + '枚=' + (pr.blob.size / 1048576).toFixed(2) + 'MB → 全' + frameCount + 'f推定 ' + (estFullOrig / 1048576).toFixed(1) + 'MB');
  } catch (pe) { console.warn('[Method A] GIFプローブ失敗(原解像度で直接へ): ' + (pe && pe.message)); }
  let curW = origW, curH = origH, curFrames = frameCount;
  if (estFullOrig && estFullOrig > TARGET) {
    const factor = TARGET / estFullOrig;
    let newShort = Math.max(MIN_SHORT, Math.round(origShort * Math.sqrt(factor)));
    const areaApplied = (newShort * newShort) / (origShort * origShort);
    if (areaApplied > factor) curFrames = Math.max(MIN_FRAMES, Math.round(frameCount * (factor / areaApplied)));
    const d = dimsFor(newShort); curW = d.w; curH = d.h;
  }
  let best = null;
  for (let attempt = 0; attempt < 6; attempt++) {
    const r = await _encodeFramesToGIFBlob(video, animCanvas, curW, curH, _frameTimes(startSec, endSec, curFrames), delayMs);
    const mb = (r.blob.size / 1048576).toFixed(1);
    console.log('[Method A] GIF試行' + attempt + ' ' + curW + 'x' + curH + ' ' + r.frames + 'f → ' + mb + 'MB');
    const area = curW * curH;
    if (r.blob.size <= limit) {
      if (!best || area > best.area) best = { blob: r.blob, meta: curW + 'x' + curH + ' ' + r.frames + 'f ' + mb + 'MB', w: curW, h: curH, frames: r.frames, area: area };
      const curShort = Math.min(curW, curH);
      if (r.blob.size < TARGET * 0.92 && curShort < origShort) {
        const grow = Math.sqrt(TARGET / r.blob.size);
        const d = dimsFor(curShort * grow);
        if (d.short <= curShort) break;
        curW = d.w; curH = d.h; continue;
      }
      break;
    }
    const overFactor = TARGET / r.blob.size;
    const curShort = Math.min(curW, curH);
    if (curShort <= MIN_SHORT) {
      if (curFrames <= MIN_FRAMES) break;
      curFrames = Math.max(MIN_FRAMES, Math.round(curFrames * overFactor));
    } else {
      let newShort = Math.max(MIN_SHORT, Math.round(curShort * Math.sqrt(overFactor)));
      if (newShort >= curShort) newShort = Math.max(MIN_SHORT, curShort - 40);
      const d = dimsFor(newShort); curW = d.w; curH = d.h;
    }
  }
  animCanvas.width = origW; animCanvas.height = origH;
  if (best) return { blob: best.blob, meta: best.meta, w: best.w, h: best.h, frames: best.frames };
  return null;
}
// ★新動画処理ロジック(フォールバック): 単一スレッドのストリーミングGIF生成(並列版が失敗した時の保険)。
//  各フレームをseek→256色量子化(gifenc)→1枚ずつ書き込み→破棄。Slidesは GIF をアニメ再生する(APNGより確実)。
async function streamingExtractEncodeGIF(video, animCanvas, startSec, endSec, frameCount, delayMs) {
    console.log('[新動画処理ロジック:GIF] ' + frameCount + 'フレーム ' + animCanvas.width + 'x' + animCanvas.height + ' を256色GIFにストリーミング生成 / cores=' + (navigator.hardwareConcurrency || '?'));
    const GIFENC_URL = 'https://cdn.jsdelivr.net/npm/gifenc@1.0.3/dist/gifenc.js';
    let src;
    try { const r = await fetch(GIFENC_URL); if (!r.ok) throw new Error('HTTP ' + r.status); src = await r.text(); }
    catch (fe) { throw new Error('[lib-fetch] gifenc取得失敗(拡張/ネットワーク遮断の可能性): ' + ((fe && fe.message) || fe)); }
    let G;
    try { const mod = { exports: {} }; (new Function('exports', 'module', src))(mod.exports, mod); G = mod.exports; }
    catch (le) { throw new Error('[gifenc-load] ' + ((le && le.message) || le)); }
    const GIFEncoder = G.GIFEncoder, quantize = G.quantize, applyPalette = G.applyPalette;
    if (!GIFEncoder || !quantize || !applyPalette) throw new Error('[gifenc-load] API未取得: ' + Object.keys(G || {}).join(','));
    const w = animCanvas.width, h = animCanvas.height;
    const ctx = animCanvas.getContext('2d', { willReadFrequently: true });
    const dur = endSec - startSec;
    const gif = GIFEncoder();
    let written = 0;
    for (let i = 0; i < frameCount; i++) {
      const p = (frameCount <= 1) ? 0 : (i / (frameCount - 1));
      const t = startSec + dur * p;
      try { await seekToTime(video, t, 15000); } catch (se) { continue; }
      ctx.drawImage(video, 0, 0, w, h);
      const rgba = ctx.getImageData(0, 0, w, h).data; // 生RGBAは常に1枚ぶんのみ
      const palette = quantize(rgba, 256);
      const index = applyPalette(rgba, palette);
      gif.writeFrame(index, w, h, { palette: palette, delay: delayMs }); // 書込後は次ループで破棄
      written++;
    }
    if (written === 0) throw new Error('[gif] 全フレームの抽出/量子化に失敗(0枚)');
    gif.finish();
    const bytes = gif.bytes();
    console.log('[新動画処理ロジック:GIF] 完了 ' + written + '/' + frameCount + '枚 → ' + Math.round(bytes.byteLength / 1024) + 'KB');
    return await new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = () => resolve(fr.result);
      fr.onerror = () => reject(new Error('[gif-base64] FileReader失敗'));
      fr.readAsDataURL(new Blob([bytes], { type: 'image/gif' }));
    });
}

async function streamingExtractEncodeAPNG(video, animCanvas, startSec, endSec, frameCount, delayMs) {
    console.log('[新動画処理ロジック:stream抽出] ' + frameCount + 'フレーム ' + animCanvas.width + 'x' + animCanvas.height + ' / cores=' + (navigator.hardwareConcurrency || '?'));
    const PACO_URL = 'https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js';
    const UPNG_URL = 'https://cdn.jsdelivr.net/npm/upng-js@2.1.0/UPNG.min.js';
    let pakoR, upngR;
    try { [pakoR, upngR] = await Promise.all([fetch(PACO_URL), fetch(UPNG_URL)]); }
    catch (fe) { throw new Error('[lib-fetch] CDN(jsdelivr)取得で例外。拡張機能/ネットワークでブロックの可能性: ' + ((fe && fe.message) || fe)); }
    if (!pakoR.ok || !upngR.ok) throw new Error('[lib-fetch] CDN取得失敗 HTTP pako=' + pakoR.status + ' upng=' + upngR.status);
    const pakoTxt = await pakoR.text(); const upngTxt = await upngR.text();
    const workerSrc = 'var window=self;\n' + pakoTxt + '\n' + upngTxt + '\n'
      + 'self.onmessage=function(e){var d=e.data;try{var png=UPNG.encode([d.buf],d.w,d.h,0,[]);self.postMessage({idx:d.idx,png:png},[png]);}catch(err){self.postMessage({idx:d.idx,error:(err&&err.message)||String(err)});}};';
    const blobUrl = URL.createObjectURL(new Blob([workerSrc], { type: 'application/javascript' }));
    const w = animCanvas.width, h = animCanvas.height, ctx = animCanvas.getContext('2d');
    const K = Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4)));
    const compressed = new Array(frameCount);
    const dur = endSec - startSec;
    const workers = [];
    for (let k = 0; k < K; k++) workers.push(new Worker(blobUrl));
    try {
      let i = 0;
      while (i < frameCount) {
        // ① Kフレームを逐次seekで抽出(生RGBAは最大K枚だけ保持)
        const batch = [];
        for (let j = 0; j < K && i < frameCount; j++, i++) {
          const p = (frameCount <= 1) ? 0 : (i / (frameCount - 1));
          const t = startSec + dur * p;
          try { await seekToTime(video, t, 15000); } catch (se) { continue; }
          ctx.drawImage(video, 0, 0, w, h);
          batch.push({ idx: i, buf: ctx.getImageData(0, 0, w, h).data.buffer });
        }
        if (batch.length === 0) continue;
        // ② バッチを並列圧縮し、転送で生RGBAを即解放
        await Promise.all(batch.map((item, bi) => new Promise((res, rej) => {
          const wk = workers[bi];
          const onmsg = (ev) => { wk.removeEventListener('message', onmsg); wk.removeEventListener('error', onerr); if (ev.data.error) { rej(new Error('worker: ' + ev.data.error)); } else { compressed[ev.data.idx] = ev.data.png; res(); } };
          const onerr = (er) => { wk.removeEventListener('message', onmsg); wk.removeEventListener('error', onerr); rej(new Error('worker error: ' + (er.message || ''))); };
          wk.addEventListener('message', onmsg); wk.addEventListener('error', onerr);
          wk.postMessage({ idx: item.idx, buf: item.buf, w: w, h: h }, [item.buf]);
        })));
        batch.length = 0;
      }
    } finally {
      workers.forEach(wk => { try { wk.terminate(); } catch (e) {} });
      URL.revokeObjectURL(blobUrl);
    }
    // ③ 圧縮済み(小)を順序通りにAPNG結合→Base64
    const order = [];
    for (let k = 0; k < frameCount; k++) { if (compressed[k]) order.push(k); }
    if (order.length === 0) throw new Error('[stream抽出] 全フレームの抽出/圧縮に失敗(0枚)');
    console.log('[新動画処理ロジック:stream抽出] 完了 ' + order.length + '/' + frameCount + '枚 → APNG結合');
    const apngBuf = _assembleAPNG(order.map(k => compressed[k]), w, h, order.map(() => delayMs));
    return await new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = () => resolve(fr.result);
      fr.onerror = () => reject(new Error('[base64] FileReader失敗'));
      fr.readAsDataURL(new Blob([apngBuf], { type: 'image/png' }));
    });
}

// ★[新規L] グラフアニメ用: RGBAフレーム配列(ArrayBuffer[])を256色GIFに結合し data URL(data:image/gif;base64,...)を返す。
//   base64FramesToArrayBuffers が返す frames(各フレームのRGBA生バッファ)・delays(ms)・width・height をそのまま渡せる。
async function createGIFFromFrames(frames, delays, width, height) {
    const G = await _getGifMain(); // gifenc ローダ(既存)
    const gif = G.GIFEncoder();
    for (let i = 0; i < frames.length; i++) {
        const rgba = new Uint8Array(frames[i]);
        // ★[fix] 透明背景が真っ黒になる不具合の対策: アルファを考慮して量子化(rgba4444+oneBitAlpha)し、
        //   透明ピクセルをGIFの透明色として書き出す(APNG同様にスライド背景へ抜ける)。
        //   ※format は quantize と applyPalette で一致必須。不透明フレームでは透明indexが見つからず従来同等。
        const palette = G.quantize(rgba, 256, { format: 'rgba4444', oneBitAlpha: true });
        const index = G.applyPalette(rgba, palette, 'rgba4444');
        let transparentIndex = -1;
        for (let p = 0; p < palette.length; p++) { const c = palette[p]; if (c && c.length >= 4 && c[3] === 0) { transparentIndex = p; break; } }
        const d = (delays && delays[i] != null) ? delays[i] : 100;
        const opts = { palette: palette, delay: d };
        if (transparentIndex >= 0) {
            opts.transparent = true;
            opts.transparentIndex = transparentIndex;
            opts.dispose = 2; // 各フレームを背景(透明)に戻してから次を描画=透明領域に前フレームが残らない
        }
        gif.writeFrame(index, width, height, opts);
    }
    gif.finish();
    const bytes = gif.bytes();
    return await new Promise((resolve, reject) => {
        const fr = new FileReader();
        fr.onload = () => resolve(fr.result); // data:image/gif;base64,...
        fr.onerror = () => reject(new Error('[gif-base64] FileReader失敗'));
        fr.readAsDataURL(new Blob([bytes], { type: 'image/gif' }));
    });
}

async function createAPNGFromFrames(frames, delays, width, height) {
    // (async function に変更)

    // ★新動画処理ロジック(experimental, 既定OFF): チェックON時はバウンド付き並列ストリーミングで生成
    //   (メモリ激減で大きい動画でも落ちにくい・並列で高速・可逆無劣化)。失敗時は従来処理へ自動フォールバック。
    try {
      if (typeof $ === 'function' && $('newVideoLogic') && $('newVideoLogic').checked) {
        console.log('[APNG] 新動画処理ロジック(並列ストリーミング)で生成します');
        return await createAPNGFromFramesNew(frames, delays, width, height);
      }
    } catch (e) {
      var __vmsg = (e && e.message) ? e.message : String(e);
      try { window.__lastVideoLogicError = { message: __vmsg, stack: e && e.stack, time: new Date().toISOString() }; } catch (e3) {}
      console.error('[新動画処理ロジック] エラー:', __vmsg, '\nstack:', (e && e.stack) || '(なし)', '\n→ 従来方式にフォールバックします。詳細は window.__lastVideoLogicError でも取得できます。');
      try { if (typeof showStatus === 'function') showStatus('動画処理を従来方式で再試行します', 'warning', 8000); } catch (e2) {}
    }

    // 0. CDNのURL (HTMLの<head>にあるもの)
    const PACO_URL = 'https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js';
    const UPNG_URL = 'https://cdn.jsdelivr.net/npm/upng-js@2.1.0/UPNG.min.js';

    let worker = null;
    let objectUrl = null;

    try {
        // 1. メインスレッドでライブラリのコードをテキストとして並列フェッチ
        console.log('[Main] Worker用ライブラリをフェッチ中...');
        const [pakoResponse, upngResponse] = await Promise.all([
            fetch(PACO_URL),
            fetch(UPNG_URL)
        ]);

        if (!pakoResponse.ok || !upngResponse.ok) {
            throw new Error(`ライブラリのフェッチに失敗 (Pako: ${pakoResponse.status}, UPNG: ${upngResponse.status})`);
        }

        const pakoScript = await pakoResponse.text();
        const upngScript = await upngResponse.text();
        console.log('[Main] ライブラリのフェッチ完了');

        // 2. 埋め込まれたワーカースクリプトのテキストを取得
        const workerScriptContent = document.getElementById('apng-worker-script').textContent;
        
        // 3. ライブラリコードとワーカーコードを結合 (window=self シム追加)
        const fullWorkerScript = `
            var window = self; /* <-- window is not defined エラー対策 */
            ${pakoScript}
            ${upngScript}
            ${workerScriptContent}
        `;

        // 4. 結合したコードからBlobを作成
        const blob = new Blob([fullWorkerScript], { type: 'application/javascript' });
        
        // 5. BlobからObject URLを生成
        objectUrl = URL.createObjectURL(blob);
        
        // 6. Object URLを使用してワーカーを起動
        worker = new Worker(objectUrl);

    } catch (e) {
        console.error('[Main] Web Workerの作成に失敗:', e);
        // (async function なので throw でOK)
        throw new Error('APNG生成ワーカーの作成に失敗しました: ' + e.message);
    }

    // --- ここから下は Promise と cleanup 処理 ---
    
    return new Promise((resolve, reject) => {
        const cleanup = () => {
            // 9. 処理完了後、ワーカーとObject URLを破棄
            if (worker) {
                worker.terminate();
            }
            if (objectUrl) {
                URL.revokeObjectURL(objectUrl);
            }
        };

        // 8. ワーカーからのメッセージ（結果）を受信
        worker.onmessage = function(event) {
            const { status, buffer, message } = event.data;
            
            if (status === 'success') {
                console.log('[Main] APNGエンコード完了 (Workerから受信)');
                // ArrayBuffer を Blob -> Base64 に変換
                const blob = new Blob([buffer], { type: 'image/png' });
                const reader = new FileReader();
                reader.onload = function(e) {
                    cleanup();
                    resolve(e.target.result); // Base64データURL
                };
                reader.onerror = function(e) {
                    cleanup();
                    reject(new Error('APNGのBase64変換に失敗しました。'));
                };
                reader.readAsDataURL(blob);
                
            } else {
                // ワーカー側でエラーが発生した場合
                console.error('[Main] APNGエンコード失敗 (Workerより):', message);
                cleanup();
                reject(new Error('APNGの生成に失敗しました: ' + message));
            }
        };

        // 8b. ワーカーのエラーハンドリング
        worker.onerror = function(error) {
            console.error('[Main] Web Worker自体でエラー:', error);
            cleanup();
            reject(new Error('APNG生成ワーカーの起動に失敗しました: ' + error.message));
        };

        // 7. メインスレッドからワーカーへ処理データを送信
        console.log(`[Main] APNGエンコード処理をWorkerに送信... ${frames.length}フレーム`);
        worker.postMessage({ frames, delays, width, height }, frames);
    });
}

/**
 * ▼▼▼ [修正版] SVGの 'animation' プロパティ (0.0-1.0) を
 * easedProgress で更新する方式に変更。
 * 値の手動スケーリング (formatValue, switch(chartType)) を削除。
 */
function generateAnimationJsonFrames(originalJsonString, frameCount) {
    const frames = [];
    let originalConfig;
    try {
        originalConfig = JSON.parse(originalJsonString);
    } catch (e) {
        console.error('generateAnimationJsonFrames: JSON parse error', e);
        return [originalJsonString];
    }

    const chartType = originalConfig.chartType;
    const originalData = originalConfig.data;
    if (!originalData) {
        console.warn('generateAnimationJsonFrames: No "data" key found', originalConfig);
        return [originalJsonString];
    }

    /**
     * イーズイン・イーズアウト（3次関数）を計算する
     * @param {number} t - 進捗 (0.0 ～ 1.0)
     * @returns {number} 補正後の進捗 (0.0 ～ 1.0)
     */
    const easeInOutCubic = (t) => {
        return t < 0.5 ?
            4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
    };

    // (ディープコピー用の関数)
    const deepCopy = (obj) => JSON.parse(JSON.stringify(obj));

    for (let i = 0; i < frameCount; i++) {
        // 1. リニアな進捗 (0.0 ... 1.0)
        const progress = (frameCount <= 1) ?
            1.0 : (i / (frameCount - 1));
        // 2. イージングを適用した進捗
        const easedProgress = easeInOutCubic(progress);
        
        // 3. 元の設定をディープコピー
        const newConfig = deepCopy(originalConfig);
        const newData = newConfig.data; // data オブジェクトを取得

        try {
            if (newData) {
                // 1. グラフデータ(newData)に、計算済みの
                //    アニメーション進捗率(easedProgress)を設定します。
                //    (小数点以下6桁に丸めています)
                newData.animation = parseFloat(easedProgress.toFixed(6));
                
                // 2. 変更後の設定を文字列化してプッシュ
                frames.push(JSON.stringify(newConfig));

            } else {
                // data キーがない不正なJSON
                console.warn(`generateAnimationJsonFrames: Frame ${i} has no "data" key.`);
                frames.push(originalJsonString);
            }
        } catch (e) {
            console.warn(`generateAnimationJsonFrames: Error processing frame ${i}`, e);
            frames.push(originalJsonString); // エラー時は元データを追加
        }
    }
    
    // frames配列が空の場合 (例: frameCount=0 だった場合)
    if (frames.length === 0) {
        frames.push(originalJsonString);
    }

    return frames;
}

    /**
     * ▼▼▼ [新規追加] PNG(Base64)フレーム配列をArrayBuffer配列に変換 ▼▼▼
     * createAPNGFromFrames に渡すためのデータを準備します。
     * @param {Array<string>} pngFramesBase64 - PNGのBase64データURLの配列
     * @returns {Promise<{frames: Array<ArrayBuffer>, width: number, height: number}>}
     */
    function base64FramesToArrayBuffers(pngFramesBase64) {
        return new Promise((resolve, reject) => {
            if (!pngFramesBase64 || pngFramesBase64.length === 0) {
                reject(new Error('base64FramesToArrayBuffers: 入力フレームがありません。'));
                return;
            }

            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            const frames = []; // ArrayBuffer
            let width = 0;
            let height = 0;

            let loadedCount = 0;
            const images = []; // 読み込んだImageオブジェクトを順序通りに保持

            pngFramesBase64.forEach((base64, index) => {
                const img = new Image();
                images[index] = img; // 順序保証のため配列に格納

                img.onload = () => {
                    loadedCount++;
                    if (index === 0) {
                        // 最初の画像でサイズを決定
                        width = img.width;
                        height = img.height;
                        canvas.width = width;
                        canvas.height = height;
                    }
                    
                    // すべての画像が読み込まれたかチェック
                    if (loadedCount === pngFramesBase64.length) {
                        // 順序通りにCanvasに描画してArrayBufferを取得
                        images.forEach(loadedImg => {
                            ctx.clearRect(0, 0, width, height); // canvasをクリア
                            ctx.drawImage(loadedImg, 0, 0, width, height);
                            const imageData = ctx.getImageData(0, 0, width, height);
                            frames.push(imageData.data.buffer);
                        });
                        resolve({ frames: frames, width: width, height: height });
                    }
                };
                img.onerror = () => {
                    reject(new Error(`base64FramesToArrayBuffers: フレーム ${index} の画像読み込みに失敗しました。`));
                };
                img.src = base64;
            });
        });
    }


    /**
   * ▼▼▼ 修正: extractFrames 関数 ▼▼▼
   * 1枚選択時は静止画、2枚選択時はAPNGを生成して返すように修正
   */
  async function extractFrames(videoFile, timestamps, quality = 480) {
    const video = document.createElement('video');
    video.muted = true;
    
    // 1. 静止画候補用のキャンバス (常に高解像度)
    const hiResCanvas = document.createElement('canvas');
    const hiResContext = hiResCanvas.getContext('2d');
    
    const extractedImages = [];
    const videoUrl = URL.createObjectURL(videoFile);
    video.src = videoUrl;

    return new Promise((resolve, reject) => {
        video.addEventListener('loadedmetadata', async () => {
            try {
                // ★newVideoLogic: 候補フレームの解像度を短辺1080に制限(大きい動画のメモリ激減＝OOM対策。スライドは~720pなので出力品質への影響は実質なし)。OFFは従来=フル解像度。
                const __nvl = (typeof $ === 'function' && $('newVideoLogic') && $('newVideoLogic').checked);
                let __cw = video.videoWidth, __ch = video.videoHeight;
                if (__nvl) {
                    const CAND_SHORT = 1080;
                    const shortEdge = Math.min(__cw, __ch) || 1;
                    if (shortEdge > CAND_SHORT) { const r = CAND_SHORT / shortEdge; __cw = Math.max(1, Math.round(__cw * r)); __ch = Math.max(1, Math.round(__ch * r)); }
                }
                hiResCanvas.width = __cw;
                hiResCanvas.height = __ch;
                console.log(`[extractFrames] 候補解像度: ${hiResCanvas.width}x${hiResCanvas.height}` + (__nvl ? ' (newVideoLogic: 短辺1080制限)' : ' (フル解像度)'));

                const videoDuration = video.duration;

                // タイムスタンプごとに処理
                // ★複数アニメ対応: まず全timestampの選択(対話)とアニメのパラメータ算出だけ行い、本数確定後にまとめてエンコードする。
                const pendingSelections = [];
                for (const timestamp of timestamps) {
                    const centerTimeSec = timeStringToSeconds(timestamp);
                    const timesToCaptureSec = [];
                    
                    // 候補時間の計算ロジック (±5秒/11枚。newVideoLogicはストリーミング抽出でメモリを抑えるので候補範囲は従来どおり広く確保)
                    for (let i = -5; i <= 5; i++) {
                        const captureTimeSec = centerTimeSec + i;
                        if (captureTimeSec >= 0 && captureTimeSec <= videoDuration) {
                            timesToCaptureSec.push(captureTimeSec);
                        }
                    }

                    const candidateFrames = [];
                    showStatus(`フレーム候補を抽出中 (${timestamp} 付近)...`, 'info', 10000);
                    
                    // 候補フレームを「高解像度」で抽出
                    for (const timeSec of timesToCaptureSec) {
                        try {
                             await seekToTime(video, timeSec);
                             // 高解像度の hiResCanvas を使用
                             hiResContext.drawImage(video, 0, 0, hiResCanvas.width, hiResCanvas.height);
                             const dataUrl = hiResCanvas.toDataURL('image/jpeg', 0.9);
                             candidateFrames.push({ timeSec: timeSec, data: dataUrl });
                        } catch (seekError) {
                             console.warn(`時間 ${timeSec}秒 のシークまたは描画に失敗しました。`, seekError);
                        }
                    }
                    
                    if (candidateFrames.length === 0) {
                         console.warn(`指定時間 ${timestamp} 付近のフレーム抽出に失敗しました。スキップします。`);
                         continue;
                    }

                    // ユーザーにモーダルで選択させる (高解像度の候補から選択)
                    showStatus(`「${timestamp}」のフレームを選択してください...`, 'info', 30000);
                    const selectionResult = await promptForFrameSelection(timestamp, candidateFrames);
                    
                    if (selectionResult.type === 'static') {
                        // --- 1枚 (静止画) 選択時 ---
                        console.log(`静止画を選択: ${timestamp} (高解像度)`);
                        pendingSelections.push({ type: 'static', timestamp: timestamp, data: selectionResult.data });

                    } else if (selectionResult.type === 'animation') {
                        // --- 2枚 (アニメーション) 選択時 ---
                        console.log(`アニメーション範囲を選択: ${timestamp}`);
                        const frame1 = selectionResult.frames[0];
                        const frame2 = selectionResult.frames[1];
                        
                        const startTime = Math.min(frame1.timeSec, frame2.timeSec);
                        const endTime = Math.max(frame1.timeSec, frame2.timeSec);
                        
                        // // APNGのパラメータ定義
                        // const duration = Math.max(0.1, endTime - startTime);
                        // const FPS = 10;
                        // const MAX_FRAMES = 200;
                        // const frameCount = Math.min(MAX_FRAMES, Math.max(2, Math.round(duration * FPS)));
                        // const delayMs = Math.round(1000 / FPS);

                        // APNGのパラメータ定義
                          const duration = Math.max(0.1, endTime - startTime); // ユーザーが選択した秒数 (例: 30秒)
                          const FPS_TARGET = 10; // 目標とするFPS
                          const MAX_FRAMES = 100; // フレーム数の上限値(newVideoLogicはストリーミング抽出でメモリ一定のため最大100コマを許可)

                          let frameCount;
                          let delayMs; // フレーム間隔 (ミリ秒)

                          // 1. 目標FPSで計算した場合の理想のフレーム数を算出
                          const idealFrameCount = Math.max(2, Math.round(duration * FPS_TARGET));

                          if (idealFrameCount > MAX_FRAMES) {
                              // --- 【A】フレーム上限を超えた場合 ---
                              // (例: 30秒 * 10FPS = 300フレーム > 200)
                              
                              // 1. フレーム数を上限値 (200) に固定
                              frameCount = MAX_FRAMES;
                              
                              // 2. 再生時間 (duration) を維持したまま、delayMs (フレーム間隔) を計算し直す
                              // (例: 30秒 * 1000ms / 200フレーム = 150ms)
                              delayMs = Math.round((duration * 1000) / frameCount);
                              
                              console.log(`[extractFrames] フレーム上限 (${MAX_FRAMES}) に達しました。FPSを ${FPS_TARGET} から 約${(1000 / delayMs).toFixed(1)} 相当に調整します。`);

                          } else {
                              // --- 【B】フレーム上限内の場合 ---
                              // (例: 3秒 * 10FPS = 30フレーム <= 200)

                              // 1. フレーム数は計算結果 (30) をそのまま使用
                              frameCount = idealFrameCount;
                              
                              // 2. delayMs は目標FPS (10) から計算
                              delayMs = Math.round(1000 / FPS_TARGET);
                          }

                        // アニメーション専用の「低解像度キャンバス」をここで定義・作成
                        //const MIN_DIMENSION_TARGET = 720; // [変更] 短辺の目標解像度
                        const MIN_DIMENSION_TARGET = quality; // [変更] 短辺の目標解像度
                        const originalWidth = video.videoWidth;
                        const originalHeight = video.videoHeight;
                        
                        // アスペクト比 (幅 / 高さ)
                        const aspectRatio = originalWidth / originalHeight; 

                        let newWidth, newHeight;

                        if (originalWidth < originalHeight) {
                            // 縦長の動画 (幅が短辺)
                            newWidth = MIN_DIMENSION_TARGET;
                            newHeight = newWidth / aspectRatio; // 高さをアスペクト比から計算
                        } else {
                            // 横長の動画 (高さが短辺)
                            newHeight = MIN_DIMENSION_TARGET;
                            newWidth = newHeight * aspectRatio; // 幅をアスペクト比から計算
                        }
                        
                        const animCanvas = document.createElement('canvas');
                        animCanvas.width = Math.round(newWidth);
                        animCanvas.height = Math.round(newHeight);
                        



                        console.log(`[extractFrames] アニメーション解像度を ${animCanvas.width}x${animCanvas.height} に設定。`);

                        // ★複数アニメ対応: ここではエンコードせず、選択とパラメータだけ記録(本数確定後にまとめて生成)。
                        pendingSelections.push({ type: 'animation', timestamp: timestamp, startTime: startTime, endTime: endTime, frameCount: frameCount, delayMs: delayMs, animCanvas: animCanvas });
                    }
                }

                // ★アニメ本数を確定。合計予算90MBを「実使用を差し引いて残り本数で再分配」する動的配分でエンコードする。
                const __animCount = pendingSelections.filter(s => s.type === 'animation').length;
                if (__animCount >= 1) console.log('[Method A] 動画アニメ要素 ' + __animCount + '本 → 合計' + (_TOTAL_ANIM_BUDGET / 1048576) + 'MB以下に動的配分(各本: min(49MB, 残り予算/残り本数)。小さく済めば残りを次へ)');
                let __remBudget = _TOTAL_ANIM_BUDGET; // 残り合計予算(バイト)
                let __remAnims = __animCount;         // 残りアニメ本数

                // ★生成フェーズ: 選択順にエンコードして extractedImages を構築(順序維持)
                for (const sel of pendingSelections) {
                    if (sel.type === 'static') {
                        extractedImages.push({ timestamp: sel.timestamp, data: sel.data, isAnimation: false });
                        continue;
                    }
                    // ★動的上限: このアニメ = min(1要素上限49MB, 残り予算/残り本数)。直前までの実使用が小さいほど大きくなる。
                    const __dynLimit = (__remAnims > 0) ? Math.min(_SLIDES_IMG_LIMIT, Math.floor(__remBudget / __remAnims)) : _SLIDES_IMG_LIMIT;
                    console.log('[Method A] アニメ ' + sel.timestamp + ': 上限 ' + (__dynLimit / 1048576).toFixed(1) + 'MB (残り予算 ' + (__remBudget / 1048576).toFixed(1) + 'MB / 残り ' + __remAnims + '本)');
                    showStatus(`動画からアニメーションを生成中... (${sel.timestamp})`, 'info', 20000);
                    let apngBase64;
                    if (__nvl) {
                      try {
                        // ★Method A(既定): アニメ(APNG優先, 上限超ならGIF)を生成→Driveへ直接アップロード→{ref,size}。
                        //   巨大JSONを作らず(クラッシュ回避)、google.script.runの~50MB制限も回避。__dynLimit=動的配分の上限。
                        const __res = await createAnimationToDrive(video, sel.animCanvas, sel.startTime, sel.endTime, sel.frameCount, sel.delayMs, __dynLimit);
                        apngBase64 = __res.ref;
                        __remBudget = Math.max(0, __remBudget - (__res.size || 0)); // ★実使用ぶんを残り予算から差し引く(余れば次のアニメへ回る)
                        console.log('[Method A] アニメ ' + sel.timestamp + ': 実使用 ' + (((__res.size || 0)) / 1048576).toFixed(1) + 'MB → 残り予算 ' + (__remBudget / 1048576).toFixed(1) + 'MB');
                      } catch (ae) {
                        const __am = (ae && ae.message) ? ae.message : String(ae);
                        try { window.__lastVideoLogicError = { message: __am, stack: ae && ae.stack, time: new Date().toISOString() }; } catch (e9) {}
                        console.error('[Method A] 失敗:', __am, (ae && ae.stack) || '');
                        try { console.error('[anim] 生成エラー:', __am); if (typeof showStatus === 'function') showStatus('アニメーションの生成に失敗しました', 'error', 14000); } catch (e8) {}
                        throw ae; // これ以上の安全策はサイズ削減のみ。generate側でこのスライドはスキップ/エラー表示。
                      }
                    } else {
                      const animData = await extractFramesForAnimation(video, sel.animCanvas, sel.startTime, sel.endTime, sel.frameCount, sel.delayMs);
                      if (animData.frames.length === 0) {
                        throw new Error('アニメーションフレームの抽出に失敗しました。');
                      }
                      apngBase64 = await createAPNGFromFrames(animData.frames, animData.delays, sel.animCanvas.width, sel.animCanvas.height);
                      __remBudget = Math.max(0, __remBudget - Math.round((apngBase64 && apngBase64.length ? apngBase64.length : 0) * 0.75)); // 旧経路: base64長から実サイズを概算して差し引く
                    }
                    __remAnims = Math.max(0, __remAnims - 1);
                    extractedImages.push({ timestamp: sel.timestamp, data: apngBase64, isAnimation: true });
                }

                try { video.pause(); video.removeAttribute('src'); video.load(); } catch (e) {} // ★動画デコーダを明示解放(下流のJSON/エディタ処理のためメモリを空ける)
                URL.revokeObjectURL(videoUrl);
                resolve(extractedImages);
            } catch (error) {
                URL.revokeObjectURL(videoUrl);
                reject(error);
            }
        });
        video.addEventListener('error', (e) => {
            URL.revokeObjectURL(videoUrl);
            reject(new Error('動画ファイルの読み込みに失敗しました。'));
        });
    });
  }
  


  /**
     * ▼▼▼ [新規追加] アニメーション用フレーム抽出 ▼▼▼
     * 指定された時間範囲から、指定枚数のフレームを抽出し、APNG生成用のデータを返します。
     * @param {HTMLVideoElement} video - 動画要素
     * @param {HTMLCanvasElement} canvas - 描画用キャンバス (サイズは動画に合わせている前提)
     * @param {number} startTimeSec - 開始時間 (秒)
     * @param {number} endTimeSec - 終了時間 (秒)
     * @param {number} frameCount - 抽出するフレーム数
     * @param {number} delayMs - 各フレームの遅延 (ms)
     * @returns {Promise<{frames: Array<ArrayBuffer>, delays: Array<number>}>} APNG生成用のフレームデータとディレイ配列
     */
    async function extractFramesForAnimation(video, canvas, startTimeSec, endTimeSec, frameCount, delayMs) {
      const context = canvas.getContext('2d');
      const frames = []; // ArrayBuffer
      const delays = []; // ms
      
      const duration = endTimeSec - startTimeSec;
      if (duration < 0 || frameCount < 2) {
        // 異常なリクエストの場合は空を返す
        console.warn('アニメーションフレーム抽出リクエストが不正です。', {duration, frameCount});
        return { frames: [], delays: [] };
      }

      console.log(`アニメーションフレーム抽出開始: ${startTimeSec.toFixed(2)}s - ${endTimeSec.toFixed(2)}s, ${frameCount}枚`);

      for (let i = 0; i < frameCount; i++) {
        // i=0 のとき progress=0, i=frameCount-1 のとき progress=1
        // (duration が 0 の場合でも startTimeSec が抽出されるように)
        const progress = (frameCount === 1) ? 0 : (i / (frameCount - 1));
        const timeSec = startTimeSec + (duration * progress);
        
        try {
          // 既存の seekToTime ヘルパーを使用
          await seekToTime(video, timeSec);
          
          // シーク完了後に描画
          context.drawImage(video, 0, 0, canvas.width, canvas.height);
          
          // ImageData を取得
          const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
          
          // ピクセルデータ (RGBAのUint8Array) のバッファを取得して配列に追加
          // UPNG.js は ArrayBuffer を要求します
          frames.push(imageData.data.buffer);
          delays.push(delayMs);

        } catch (seekError) {
          console.warn(`アニメーションフレームの時間 ${timeSec.toFixed(2)}s のシークまたは描画に失敗しました。`, seekError);
          // 1フレーム失敗しても続行
        }
      }
      
      console.log(`アニメーションフレーム抽出完了: ${frames.length}枚`);
      return { frames, delays };
    }

    /**
     * ▼▼▼ [修正版] PDF指定ページを画像化 (即時実行・レイアウト選択限定版) ▼▼▼
     */
    function promptForPdfPage(pageNumber, suggestedTitle) {
      return new Promise(async (resolve, reject) => {
        
        // ============================================================
        // 1. DOM要素と変数の初期化
        // ============================================================
        const layoutSelect = $('pdfLayoutSelect');
        const modal = $('pdfPromptModal');
        const promptElement = $('pdfPromptText');
        const dropZone = $('pdfDropZone');
        const pdfUpload = $('pdfUpload');
        const dropZoneText = $('pdfDropZoneText');
        const fileSelectLink = $('fileSelectLinkPdf');
        const confirmBtn = $('confirmPdfBtn');
        const cancelBtn = $('cancelPdfBtn');
        
        // 不要になった要素もエラー回避のため取得だけしておく
        const previewCanvas = $('pdfPreviewCanvas');
        const settingsArea = $('pdfPageSettingsArea');
        const listContainer = $('pdfPageListContainer');
        
        if (settingsArea) settingsArea.style.display = 'none';
        if (listContainer) listContainer.innerHTML = '';

        let tempPdfDoc = null; 

        // ============================================================
        // 2. ヘルパー関数・イベントハンドラの定義
        // ============================================================

        // プレビュー表示用関数 (今回は裏側で使われる可能性があるため残すが、表示はしない)
        const renderPageFromPdf = async (pdfDoc) => {
          if (pageNumber > pdfDoc.numPages || pageNumber < 1) {
            throw new Error(`このPDFは全${pdfDoc.numPages}ページです。指定された${pageNumber}ページ目は存在しません。`);
          }
          // 一時的なキャンバスを作成してレンダリング
          const tempCanvas = document.createElement('canvas');
          const page = await pdfDoc.getPage(pageNumber);
          const viewport = page.getViewport({ scale: 1.5 });
           
          const context = tempCanvas.getContext('2d');
          tempCanvas.height = viewport.height;
          tempCanvas.width = viewport.width;
          await page.render({ canvasContext: context, viewport: viewport }).promise;
          return tempCanvas.toDataURL('image/jpeg', 0.85);
        };

        const clickHandler = (e) => { e.preventDefault(); pdfUpload.click(); };
        const changeHandler = () => { if (pdfUpload.files.length > 0) handleFile(pdfUpload.files[0]); };
        const dragOverHandler = (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); };
        const dragLeaveHandler = () => dropZone.classList.remove('drag-over');
        const dropHandler = (e) => {
          e.preventDefault();
          dropZone.classList.remove('drag-over');
          if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
        };
        const cancelHandler = () => { cleanup(); reject(new Error('PDF処理なしで続行')); };

        function cleanup() {
          if (fileSelectLink) fileSelectLink.removeEventListener('click', clickHandler);
          if (pdfUpload) pdfUpload.removeEventListener('change', changeHandler);
          if (dropZone) {
              dropZone.removeEventListener('dragover', dragOverHandler);
              dropZone.removeEventListener('dragleave', dragLeaveHandler);
              dropZone.removeEventListener('drop', dropHandler);
          }
          if (confirmBtn) confirmBtn.removeEventListener('click', confirmHandler);
          if (cancelBtn) cancelBtn.removeEventListener('click', cancelHandler);
          
          if (modal) modal.style.display = 'none';
          if (pdfUpload) pdfUpload.value = '';
        }

        // 処理実行ハンドラ
        const confirmHandler = async () => {
            const docToUse = tempPdfDoc || cachedNotebookLmPdf;

            if (docToUse) {
                cachedNotebookLmPdf = docToUse;
                const selectedLayout = layoutSelect ? layoutSelect.value : 'full_layout_with_margins'; // デフォルト: まじん式

                if (selectedLayout) {
                    localStorage.setItem('slideFactory_lastPdfLayout', selectedLayout);
                }
                
                // 通常の画像生成処理
                try {
                    let finalImg;
                    if (selectedLayout === 'full_layout_with_margins') {
                        // まじん式レイアウト (余白調整)
                        //showStatus("まじん式レイアウトで処理中...", "info", 3000);
                        const page = await docToUse.getPage(pageNumber);
                        const result = await IntegratedLayoutEngine.processSinglePage(page);
                        finalImg = result.mergedData;
                    } else {
                        // PDFレイアウト (ページ全体)
                        //showStatus("PDFレイアウトで処理中...", "info", 3000);
                        finalImg = await renderPageFromPdf(docToUse);
                    }
                    
                    cleanup();
                    resolve({ data: finalImg, layout: selectedLayout });
                } catch(e) {
                    console.error('[ist] 画像生成エラー:', e); showStatus('画像の生成に失敗しました', 'error');
                }
            }
        };

        // ★★★ 修正: ファイルドロップ時の処理 (プレビューなし・即時実行) ★★★
        async function handleFile(file) {
          if (!file || file.type !== 'application/pdf') {
            showStatus('エラー: PDFファイルを選択してください。', 'error');
            return;
          }
          
          showStatus(`PDFを読み込み中...`, 'info', 2000);
          try {
            const arrayBuffer = await file.arrayBuffer();
            const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
            
            tempPdfDoc = pdf; 
            
            // ★ プレビュー表示やチェックボックス生成をスキップし、直ちに処理を実行
            console.log("PDF loaded. Auto-executing confirmHandler.");
            await confirmHandler();

          } catch (e) {
            console.error(e);
            showStatus('PDFの解析に失敗しました: ' + e.message, 'error');
          }
        }


        // ============================================================
        // 3. キャッシュ済みPDFの自動処理
        // ============================================================
        if (cachedNotebookLmPdf) {
            console.log(`[NotebookLM] キャッシュされたPDFから ${pageNumber} ページ目を抽出します。`);
            try {
                await confirmHandler();
                return; 
            } catch (e) {
                console.warn("キャッシュPDFからの抽出失敗。再アップロードを求めます。", e);
                cachedNotebookLmPdf = null;
            }
        }


        // ============================================================
        // 4. 新規アップロード用モーダルの表示
        // ============================================================
        
        // レイアウト選択の初期化
        if (layoutSelect) {
            // ▼▼▼ [変更] ローカルストレージから前回の設定を読み込む ▼▼▼
            const lastLayout = localStorage.getItem('slideFactory_lastPdfLayout');
            if (lastLayout) {
                layoutSelect.value = lastLayout; // 前回の値があれば適用
            } else {
                layoutSelect.value = 'full_layout_with_margins'; // なければデフォルト: まじん式
            }
            // ▲▲▲ [変更] ここまで ▲▲▲
        }
        
        // ★ 文言の変更
        promptElement.innerHTML = `タイトル「${escapeHTML(suggestedTitle)}」のPDFをアップロードしてください。`;
        
        if (previewCanvas) previewCanvas.style.display = 'none';
        if (dropZoneText) dropZoneText.style.display = 'block';
        
        modal.style.display = 'flex';

        // イベントリスナー登録
        fileSelectLink.addEventListener('click', clickHandler);
        pdfUpload.addEventListener('change', changeHandler);
        dropZone.addEventListener('dragover', dragOverHandler);
        dropZone.addEventListener('dragleave', dragLeaveHandler);
        dropZone.addEventListener('drop', dropHandler);
        // confirmBtnは非表示だがロジック上必要なら残す
        confirmBtn.addEventListener('click', confirmHandler);
        cancelBtn.addEventListener('click', cancelHandler);
      });
    }

  // =====================================================================
  // ★[要望1] NotebookLMN プレースホルダのJSON読込時 自動PDF分解フロー
  //   画像(image)の値が "NotebookLM<ページ番号>" のスライドを検出したら、
  //   PDFを1回アップロードさせ、該当ページを画像化→splitImage(Nanobanana Editor相当)へ
  //   自動分解してエディタに展開する。ユーザーは各スライドを調整してから生成できる。
  //   キャンセル時は何もしない(生成時の既存 promptForPdfPage フローがフォールバックとして働く)。
  // =====================================================================

  // pdfPromptModal を再利用して「PDFファイルだけ」取得(レイアウト選択UIは隠す。キャンセルボタンで中断可)
  function promptForPdfFileOnly(message) {
    return new Promise((resolve) => {
      const modal = document.getElementById('pdfPromptModal');
      const promptEl = document.getElementById('pdfPromptText');
      const dropZone = document.getElementById('pdfDropZone');
      const pdfUpload = document.getElementById('pdfUpload');
      const fileLink = document.getElementById('fileSelectLinkPdf');
      const cancelBtn = document.getElementById('cancelPdfBtn');
      const layoutSel = document.getElementById('pdfLayoutSelect');
      if (!modal || !dropZone || !pdfUpload) { resolve(null); return; }
      // ★ NotebookLM取込: レイアウト選択(PDFレイアウト / まじん式レイアウト)を表示して選ばせる。
      //   (従来は非表示でPDF(raw)固定だった。まじん式=背景を広げて余白を作りまじん要素を入れる。)
      const layoutWrap = (layoutSel && layoutSel.closest) ? layoutSel.closest('.form-group') : (layoutSel ? layoutSel.parentElement : null);
      if (layoutWrap) layoutWrap.style.display = '';
      // ★ユーザー要望: 説明文言は出さない。メッセージ指定時のみ表示、空なら<p>ごと非表示。
      if (promptEl) { promptEl.textContent = message || ''; promptEl.style.display = message ? '' : 'none'; }
      modal.style.display = 'flex';
      const finish = (file) => { const layout = layoutSel ? layoutSel.value : 'pdf_layout'; cleanup(); resolve(file ? { file: file, layout: layout } : null); };
      const onChange = () => { if (pdfUpload.files && pdfUpload.files.length) finish(pdfUpload.files[0]); };
      const onClick = (e) => { e.preventDefault(); pdfUpload.click(); };
      const onDrop = (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files && e.dataTransfer.files.length) finish(e.dataTransfer.files[0]); };
      const onOver = (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); };
      const onLeave = () => dropZone.classList.remove('drag-over');
      const onCancel = () => finish(null);
      function cleanup() {
        modal.style.display = 'none';
        pdfUpload.value = '';
        if (layoutWrap) layoutWrap.style.display = '';
        if (promptEl) promptEl.style.display = ''; // ★共有要素(processPdf)のため表示を復元
        pdfUpload.removeEventListener('change', onChange);
        if (fileLink) fileLink.removeEventListener('click', onClick);
        dropZone.removeEventListener('drop', onDrop);
        dropZone.removeEventListener('dragover', onOver);
        dropZone.removeEventListener('dragleave', onLeave);
        if (cancelBtn) cancelBtn.removeEventListener('click', onCancel);
      }
      pdfUpload.addEventListener('change', onChange);
      if (fileLink) fileLink.addEventListener('click', onClick);
      dropZone.addEventListener('drop', onDrop);
      dropZone.addEventListener('dragover', onOver);
      dropZone.addEventListener('dragleave', onLeave);
      if (cancelBtn) cancelBtn.addEventListener('click', onCancel);
    });
  }

  let __lastNlmAutoSig = '';
  async function autoDecomposeNotebookLm() {
    try {
      const ta = document.getElementById('slideDataInput');
      if (!ta) return false;
      const raw = ta.value || '';
      if (raw.indexOf('NotebookLM') === -1) return false;      // 高速スキップ
      if (window.__nlmAutoProcessing) return false;             // 多重起動防止
      const sig = raw.length + ':' + raw.indexOf('NotebookLM'); // 同一内容の再プロンプト防止用の軽量シグネチャ
      if (sig === __lastNlmAutoSig) return false;

      const editorActive = (typeof SlideEditor !== 'undefined' && SlideEditor.isEditorActive && SlideEditor.isEditorActive());
      let slideData;
      try { slideData = editorActive ? SlideEditor.getSlideData() : JSON.parse(raw); }
      catch (e) { return false; }                               // 入力途中の不完全JSONはスキップ
      if (!Array.isArray(slideData)) return false;

      // 対象収集: image キーが "NotebookLM<page>"(未分解の文字列)のスライド
      const rx = /^NotebookLM(\d+)$/i;
      const targets = [];
      slideData.forEach((s, i) => {
        if (s && typeof s.image === 'string') {
          const m = s.image.trim().match(rx);
          if (m) targets.push({ i, page: parseInt(m[1], 10) });
        }
      });
      __lastNlmAutoSig = sig; // プロンプト前に確定(キャンセルしても同一内容で再プロンプトしない)
      if (!targets.length) return false;
      if (typeof BatchImageSplit === 'undefined' || !BatchImageSplit.decomposeOne) return false;

      window.__nlmAutoProcessing = true;
      try {
        const picked = await promptForPdfFileOnly(''); // ★ユーザー要望: 説明文言は表示しない
        if (!picked || !picked.file) { showStatus('PDFアップロードをキャンセルしました。生成時に改めて指定できます。', 'info', 6000); return false; }
        const file = picked.file;
        const __useMajinLayout = (picked.layout === 'full_layout_with_margins'); // ★まじん式レイアウト=背景拡張で余白を作る / それ以外=PDFレイアウト(raw)
        let pdf;
        try { const ab = await file.arrayBuffer(); pdf = await pdfjsLib.getDocument({ data: ab }).promise; }
        catch (e) { showStatus('PDFの読み込みに失敗しました: ' + (e && e.message ? e.message : e), 'error'); return false; }
        cachedNotebookLmPdf = pdf; // ★[二重アップロード防止] 生成時 promptForPdfPage がこのキャッシュを再利用し、再アップロードのモーダルを出さない(範囲外/失敗ページが残っても無人で処理)。

        // エディタへ入る(まだなら)。以降は SlideEditor のライブデータを編集する
        try {
          if (!editorActive && typeof SlideEditor !== 'undefined') {
            ta.value = JSON.stringify(slideData, null, 2);
            SlideEditor.enterEditorMode();
            slideData = SlideEditor.getSlideData() || slideData;
          }
        } catch (e) { console.warn('[NLM] エディタ遷移失敗', e); }

        // ★[要望] 直接PDF取込と同様、変換中はブロッキング進捗オーバーレイ(batchSplitOverlay)で操作不可にする(toastではなくモーダル)。
        const __nlmOverlay = !!(typeof BatchImageSplit !== 'undefined' && BatchImageSplit.showProgress);
        if (__nlmOverlay) { BatchImageSplit.resetCancel(); BatchImageSplit.showProgress('NotebookLM資料を画像分解しています…'); }
        else { showStatus('NotebookLM資料を画像分解中…（しばらくお待ちください）', 'info', 60000); }
        let done = 0;
        for (let n = 0; n < targets.length; n++) {
          if (__nlmOverlay && BatchImageSplit.isCancelled()) break;
          if (__nlmOverlay) { BatchImageSplit.setSub('画像分解中… ' + (n + 1) + '/' + targets.length + ' ページ'); BatchImageSplit.setBar(n / targets.length); }
          const t = targets[n];
          if (!slideData[t.i]) continue;
          if (t.page < 1 || t.page > pdf.numPages) { console.warn('[NLM] ページ範囲外', t.page, '/', pdf.numPages); continue; }
          let img64;
          try { img64 = __useMajinLayout ? await BatchImageSplit.renderPageMajin(pdf, t.page) : await BatchImageSplit.renderPageRaw(pdf, t.page); }
          catch (e) { console.warn('[NLM] ページ画像化失敗', t, e); continue; }
          slideData[t.i].image = { data: img64, originalData: img64 };
          try { if (await BatchImageSplit.decomposeOne(slideData, t.i, 'image', img64)) done++; }
          catch (e) { console.warn('[NLM] 分解失敗', t, e); }
          await new Promise(r => setTimeout(r, 0));
        }
        if (__nlmOverlay) { BatchImageSplit.setBar(1); BatchImageSplit.hideProgress(); }

        ta.value = JSON.stringify(slideData, null, 2);
        __lastNlmAutoSig = (ta.value.length + ':' + ta.value.indexOf('NotebookLM')); // 分解後の内容で更新(再トリガ防止)
        try {
          if (typeof SlideEditor !== 'undefined') {
            if (SlideEditor.isEditorActive()) SlideEditor.renderEditorUI();
            else SlideEditor.enterEditorMode();
          }
        } catch (e) { console.warn(e); }
        showStatus('画像分解しました', 'success', 6000);
        return true;
      } finally {
        window.__nlmAutoProcessing = false;
      }
    } catch (e) {
      console.error('[NLM] autoDecomposeNotebookLm 失敗', e);
      window.__nlmAutoProcessing = false;
      return false;
    }
  }

  // slideDataInput への読込(貼付け/ファイル取込/dispatch)を監視し、NotebookLMN を検出したら自動分解(デバウンス)
  function _wireNotebookLmAutoTrigger() {
    const ta = document.getElementById('slideDataInput');
    if (!ta || ta.dataset.nlmAutoWired) return;
    ta.dataset.nlmAutoWired = '1';
    let timer = null;
    const schedule = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { autoDecomposeNotebookLm(); }, 700); };
    ta.addEventListener('input', schedule);
    ta.addEventListener('change', schedule);
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _wireNotebookLmAutoTrigger);
  else _wireNotebookLmAutoTrigger();





// --- calculateTextWidthPt ヘルパー関数 (新規追加) ---
  /**
   * 指定されたテキストの幅をポイント単位で計算します。
   * @param {string} text - 幅を計算するテキスト。
   * @param {number} fontSizePt - フォントサイズ (ポイント単位)。
   * @param {string} fontFamily - フォントファミリー名。
   * @param {string} [fontWeight='normal'] - フォントウェイト ('normal' or 'bold')。
   * @returns {number|null} 計算された幅 (ポイント単位)、または計算失敗時に null。
   */
  function calculateTextWidthPt(text, fontSizePt, fontFamily, fontWeight = 'normal') {
    try {
      const canvas = document.createElement('canvas');
      const context = canvas.getContext('2d');
      // ポイントをピクセルに変換 (標準的な 96 DPI を想定)
      const fontSizePx = fontSizePt * (96 / 72);
      // context.font に設定
      context.font = `${fontWeight} ${fontSizePx}px "${fontFamily}"`;
      // 幅をピクセル単位で測定
      const metrics = context.measureText(text || ''); // テキストが空でもエラーにならないように
      const widthPx = metrics.width;
      // ピクセルをポイントに変換して返す
      return widthPx * 0.75;
    } catch (e) {
      console.error("Text width calculation error:", e);
      return null; // エラー時は null を返す
    }
  }




/**
 * [修正版] 構成データ（JSON）をサーバーに送信し、スライドを生成または更新します。
 * 1. 編集モードの場合、最新の編集内容をtextareaに書き戻します。
 * 2. textareaのJSONをパースし、画像処理（Base64化）を行います。
 * 3. サーバーにリクエストを送信します。
 * 4. プレビューを表示し、ボタンの状態を現在のモード（編集/テキスト）に応じて正しく設定します。
 */
async function generate() {
  console.log('[generate] --- スライド生成/更新プロセス開始 ---');
  // ★[二重アップロード防止] ここで cachedNotebookLmPdf を null にしない。
  //   生成前の自動分解(autoDecomposeNotebookLm)がアップロード済みPDFをキャッシュに載せているため、
  //   残プレースホルダ(範囲外/失敗ページ等)があっても promptForPdfPage がキャッシュを再利用し再アップロードを回避する。
  //   別PDFへ切り替える際は NotebookLM JSON 再貼付で自動分解が走り、キャッシュが更新される(自己修復)。
  const generateBtn = $('generateBtn');
  // ★[要望4] 排他制御: ページ単位更新など他の処理が走っている間は実行しない(二重起動防止)
  if (window.isSlideJobRunning) { try { showStatus('処理中です。完了までお待ちください。', 'info'); } catch (e) {} return; }
  // ▼▼▼ [移植] 編集モードなら、先にJSONデータをtextareaに書き戻す ▼▼▼
  if (SlideEditor && SlideEditor.isEditorActive()) {
      console.log('[generate] 編集モードのため、SlideEditorからJSONデータをtextareaに書き戻します。');
      try {
          const currentEditorData = SlideEditor.getSlideData();
          const jsonString = JSON.stringify(currentEditorData, null, 2); // 整形して書き戻す
          $('slideDataInput').value = jsonString;
          console.log('[generate] textareaの更新完了。');
      } catch (e) {
          console.error('[generate] SlideEditorデータのJSON文字列化に失敗:', e);
          showJsonErrorModal('編集内容のJSON変換に失敗しました: ' + e.message); // エラーモーダルを出す
          return; // 処理を中断
      }
  }
  // ▲▲▲ [移植] ここまで ▲▲▲

  let slideDataString = $('slideDataInput').value;
  if (!slideDataString.trim()) {
    // [変更] 編集モードでクリアした場合も考慮し、エラーモーダルではなくトーストにする
    showStatus('エラー: スライド構成データが空です。', 'error'); 
    console.error('[generate] エラー: スライド構成データが空です。');
    return;
  }

  let slideData;

  try {
    try {
      slideData = JSON.parse(slideDataString);
    } catch (__e0) {
      // ★自動修復(2026-09-05): 貼り付け時の validateJSON を通らなかった経路(ファイル取込・エディタ等)の保険
      const __rep = repairSlideDataJson(slideDataString);
      // ★2026-09-07: 中身を見て止めた場合は、利用者向けの案内文をそのまま出して終わる
      if (!__rep.ok && __rep.guard) { showJsonErrorModal(__rep.error); return; }
      if (!__rep.ok) throw __e0;
      slideData = __rep.data;
      slideDataString = __rep.text;
      $('slideDataInput').value = __rep.text;
      console.log('[generate] JSONを自動修復しました', __rep.fixes);
      showStatus('構成データのJSONを自動修復しました', 'success', 6000);
    }
    if (!Array.isArray(slideData)) throw new Error('slideDataは配列である必要があります');
    
    // ▼▼▼ [修正] 配列内の空文字列をフィルタリング ▼▼▼
    slideData = filterEmptyStringsInArrays(slideData);
    console.log(`[generate] 空文字列をフィルタリング後のslideData: ${slideData.length}枚`);
    // ▲▲▲ [修正] ここまで ▲▲▲

    console.log(`[generate] JSONパース成功: ${slideData.length}枚のスライド`); 
  } catch (e) {
    showJsonErrorModal('JSONパースエラー: ' + e.message); 
    console.error('[generate] JSONパースエラー:', e.message);
    return; 
  }



  const settings = getCurrentSettings();
  console.log('[generate] 設定読み込み完了:', settings);

  generateBtn.disabled = true;
  startButtonTimer(generateBtn);
  setSlideJobRunning(true, 'generateBtn'); // ★[要望4] 実行中は「ページ更新」ボタンをロック(グレーアウト)
  try {

    if (ENABLE_CREDIT_IMAGE === true) {
          // ★ クレジットはテキストで配置(画像化しない)。文言だけを選んでサーバーに渡す。
          const creditText = CREDIT_TEXT_OPTIONS[Math.floor(Math.random() * CREDIT_TEXT_OPTIONS.length)];
          settings.creditText = creditText;
          settings.creditImageBase64 = null;
          console.log('[generate] クレジットテキスト:', creditText);
        } else {
          console.log('[generate] クレジットは無効です。');
          settings.creditText = null;
          settings.creditImageBase64 = null;
        }

    let presentationUrl = null;
    let isNewPresentation = false;
    // --- 既存プレゼンテーションID取得 (変更なし) ---
    let presentationId = null;
    if (lastGeneratedSlideUrl) {
      console.log('[generate] 既存のプレゼンテーションURLからIDを抽出中:', lastGeneratedSlideUrl);
      const match = lastGeneratedSlideUrl.match(/\/d\/([a-zA-Z0-9_-]+)|\?id=([a-zA-Z0-9_-]+)/);
      if (match && (match[1] || match[2])) {
        presentationId = match[1] || match[2];
        console.log(`[generate] 既存プレゼンテーションID取得成功: ${presentationId}`);
      } else {
        console.warn('[generate] URLからIDの抽出に失敗しました。新規作成します。');
      }
    } else {
      console.log('[generate] 既存のプレゼンテーションURLなし。新規作成します。');
    }

    // --- 画像更新オプションの確認 (変更なし) ---
    let imageUpdateOption = 'update'; // ★デフォルトを 'update' に設定
    
    if (presentationId) {
      // 既存スライドの更新モードの場合
      console.log("[generate] 更新モードです。画像オプションは 'update' に固定されます。(モーダルスキップ)");
      // imageUpdateOption = await promptImageUpdateOption(); // ← 元のモーダル呼び出しをコメントアウト
      // ★ try...catch (キャンセル処理) も不要になります
    } else {
      // 新規作成モードの場合
      console.log('[generate] 新規作成モードです。画像は常に更新されます。');
      imageUpdateOption = 'update'; // (元々 'update' 扱いだったが明示)
    }

    // ★[テンプレ準備] テンプレ指定時は先にキャッシュを用意する（時間がかかるときだけ「テンプレートを準備中」を出す）
    if (settings && settings.templateUrl && String(settings.templateUrl).trim()) {
      try { await _prepareTemplateBeforeGenerate(String(settings.templateUrl).trim()); } catch (e) { console.warn('[generate] テンプレ準備をスキップ:', e); }
    }

    // ▼▼▼ 【ここから追加】 空スライドの先行作成ロジック ▼▼▼
    if (!presentationId) {
      isNewPresentation = true;
      window.isInitialGenerating = true; // ★[要望3] 初回生成中フラグ(完了まで「ページ更新」ボタンを出さない)
      console.log('[generate] 新規作成モードです。空のプレゼンテーションを先行作成します。');
      const statusId = showStatus('Googleスライドファイルを作成しています...', 'info', 15000);

      try {
        // ▼▼▼ 【修正】 送信データを軽量化（画像データを除去） ▼▼▼
        // タイトル決定に必要な情報だけを残し、重い画像データは削除して送信します
        const lightSlideData = slideData.map(s => {
            const clone = Object.assign({}, s);
            // 巨大なBase64が入る可能性のあるプロパティを削除
            delete clone.image;
            delete clone.backgroundImage; // 統合レイアウト用
            delete clone.foregroundImage; // 統合レイアウト用
            delete clone.ghostImageBase64;
            return clone;
        });

        // settingsからも重い画像を一時的に除外したコピーを作成
        const lightSettings = Object.assign({}, settings);
        delete lightSettings.creditImageBase64;
        delete lightSettings.triangleArrows;

        // ★ 新しいサーバー関数を呼び出し (軽量データを渡す) ★
        presentationUrl = await new Promise((resolve, reject) => {
          google.script.run
            .withSuccessHandler(resolve)
            .withFailureHandler(reject)
            .createBlankPresentation(lightSlideData, lightSettings); // ◀ 軽量データを渡す
        });
        


        if (statusId) { const el = $(statusId); if (el) el.remove(); } // 「作成中」を消す
        
        lastGeneratedSlideUrl = presentationUrl;
        console.log(`[generate] 空のスライド作成成功: ${presentationUrl}`);
        
        // 新しいURLからIDを抽出
        const match = presentationUrl.match(/\/d\/([a-zA-Z0-9_-]+)|\?id=([a-zA-Z0-9_-]+)/);
        if (match && (match[1] || match[2])) {
          presentationId = match[1] || match[2];
          console.log(`[generate] 新規プレゼンテーションID取得成功: ${presentationId}`);
        } else {
          throw new Error('先行作成されたスライドURLからIDを抽出できませんでした。');
        }

        // ★★★【ロジックの核心】プレビューを先行して表示 ★★★
        showPreviewPane();
        $('previewOverlay').classList.add('loading');

        // ★ 新規作成時は、画像オプションを 'update' に強制
        imageUpdateOption = 'update';
        console.log(`[generate] 新規作成のため、画像オプションは 'update' です。`);

      } catch (blankCreateError) {
        if (statusId) { const el = $(statusId); if (el) el.remove(); }
        console.error('[generate] 空のスライドの先行作成に失敗:', blankCreateError);
        showStatus('エラー: スライドの先行作成に失敗しました: ' + (blankCreateError.message || blankCreateError), 'error', 8000);
        // (finallyブロックでボタンは有効に戻る)
        return; // 処理中断
      }
    }
    // ▲▲▲ 【追加ここまで】 ▲▲▲


    // --- テキスト幅計算 (変更なし) ---
    console.log('[generate] --- テキスト幅計算開始 ---');
    // ★[新規I] 途中経過の細かいトーストは抑制(開始/終了のざっくり通知に絞る)
    // showStatus('スライド素材の準備中...', 'info', 3000);
    try {
      let titlesCalculated = 0;
      slideData.forEach((slide, index) => {
        const configSizes = typeof CONFIG !== 'undefined' ? CONFIG.FONTS.sizes : {};
        const fontFamily = settings.fontFamily || 'Noto Sans JP';
        if (slide && typeof slide.title === 'string' && slide.title.trim() !== '') {
          let fontSizePt;
          let fontWeight = 'bold';


          if (slide.type === 'title') fontSizePt = configSizes.title || 41;
   
          else if (slide.type === 'section') fontSizePt = configSizes.sectionTitle || 38;
          else fontSizePt = configSizes.contentTitle || 24;

          const widthPt = calculateTextWidthPt(slide.title, fontSizePt, fontFamily, fontWeight);

          if (widthPt !== null) {
            slide._title_widthPt = widthPt;
            titlesCalculated++;
          } else 
            console.warn(`[generate] スライド ${index + 1}: Title 幅計算失敗`);
        }
        if (slide && typeof slide.subhead === 'string' && slide.subhead.trim() !== '') {
          const fontSizePt = configSizes.subhead ||
            16;
          const fontWeight = 'normal';

          const widthPt = calculateTextWidthPt(slide.subhead, fontSizePt, fontFamily, fontWeight);
          if (widthPt !== null) {
            slide._subhead_widthPt = widthPt;
          } else console.warn(`[generate] スライド ${index + 1}: Subhead 幅計算失敗`);
        }
      });
      console.log(`[generate] テキスト幅計算完了: ${titlesCalculated}個のタイトルを処理`);
    } catch (e) {
      console.error('[generate] テキスト幅計算中にエラー:', e);
      showStatus('テキスト幅の計算に失敗しました（続行）', 'warning', 5000);
    }

    // --- ゴースト番号画像生成 (変更なし) ---
    if (imageUpdateOption === 'update') {
      console.log('[generate] --- ゴースト番号画像生成開始 ---');
      //showStatus('セクション番号画像を生成中...', 'info', 5000);
      try {
        const ghostSettings = {
          baseFrequency: 0.01,
          colorType: 'gray',
          alphaSlope: 0.50,
          contrastSlope: 1.0,
          frontColor: settings.primaryColor,
          frontAlpha: ((+settings.ghostTopAlpha || 0.35) + (+settings.ghostBottomAlpha || 0.2)) / 2
        };
        slideData = await generateGhostNumberImages(slideData, ghostSettings);
        console.log('[generate] ゴースト番号画像生成完了');
      } catch (e) {
        console.error('[generate] ゴースト番号画像生成プロセス全体でエラー:', e);
        showStatus('セクション番号を代替表示にしました', 'warning', 5000);
      }
    } else {
      console.log('[generate] 画像保持オプションのためゴースト番号生成をスキップ');
    }


    // --- Triangle矢印画像生成 (変更なし) ---
    if (imageUpdateOption === 'update') {
      console.log('[generate] --- Triangle矢印画像生成開始 ---');
      //showStatus('Triangle用矢印画像を生成中...', 'info', 10000);
      let triangleArrowData = {};
      try {
        triangleArrowData = await generateTriangleArrowImages(settings);
        settings.triangleArrows = triangleArrowData;
        console.log('[generate] Triangle矢印画像生成完了');
      } catch (e) {
        console.error('[generate] Triangle矢印画像生成プロセス全体でエラー:', e);
        showStatus('矢印を代替表示にしました', 'warning', 5000);
        settings.triangleArrows = null;
      }
    } else {
      settings.triangleArrows = null;
      console.log('[generate] 画像保持オプションのためTriangle矢印生成をスキップ');
    }


    // --- GoogleドライブフォルダID抽出 (変更なし) ---
    console.log('[generate] GoogleドライブフォルダIDを抽出中...');
    const extractedId = ((url) => {
      if (!url) return null;
      const drivePatterns = [
        /\/folders\/([a-zA-Z0-9_-]+)/,
        /\?id=([a-zA-Z0-9_-]+)/
      ];
      for (const pattern of drivePatterns) {

        const match = url.match(pattern);
        if (match && match[1]) return match[1];
      }
      if (/^[a-zA-Z0-9_-]{25,}$/.test(url)) return url;
     
      return null;
    })(settings.driveFolderUrl);
    const isMyDriveUrl = settings.driveFolderUrl.includes('/drive/my-drive');
    if (settings.driveFolderUrl && !extractedId && !isMyDriveUrl) {
      showStatus('エラー: 正しいGoogleドライブフォルダURLまたはIDを入力してください。', 'error');
      console.error('[generate] エラー: 不正なドライブフォルダURL:', settings.driveFolderUrl);
      generateBtn.disabled = false;
      return;
    }
    settings.driveFolderId = extractedId;
    console.log(`[generate] GoogleドライブフォルダID: ${extractedId || '(マイドライブ)'}`);


    // --- 画像/タイムスタンプ/SVG/グラフJSONの分類と処理 ---
    let finalSlideDataString = slideDataString;
    if (imageUpdateOption === 'update') {
      console.log('[generate] --- スライド内の画像要素を分類・処理中 (画像更新モード) ---');
      const urlRegex = /^(https?:\/\/|data:image\/)/;
      const timestampRegex = /^\d{2}:\d{2}(\.\S)?.*$/;
      const notebookLmRegex = /^NotebookLM(\d+)$/i;
      const svgRegex = /^\s*<svg/i;
      
      const timestamps = new Set();
      const textPromptsMap = new Map();
      const svgCodes = new Set();
      const chartJsons = new Set();
      const notebookLmItems = new Set();

      // ★★★ 修正: 複数のキーを監視対象にする ★★★
      const imageKeys = ['image', 'backgroundImage', 'foregroundImage'];

      slideData.forEach(slide => {
        imageKeys.forEach(key => {
            // [互換修正] {chartType:'svg', data:'<svg…>'} 形式（旧Geminiが生SVGをチャートオブジェクトで包んで出力した形）を
            // 生SVG文字列へ正規化し、未対応の chart テンプレート経路ではなく既存の生SVG経路で処理する
            if (slide[key] && typeof slide[key] === 'object' && slide[key].chartType === 'svg'
                && typeof slide[key].data === 'string' && /^\s*<svg/i.test(slide[key].data)) {
              slide[key] = slide[key].data;
            }
            const imageValue = slide[key];
            let identifiedType = null;

            if (typeof imageValue === 'string' && imageValue.trim() !== '') {
              const trimmedValue = imageValue.trim();
              if (svgRegex.test(trimmedValue)) identifiedType = 'svg';
              else if (timestampRegex.test(trimmedValue)) identifiedType = 'timestamp';
              else if (notebookLmRegex.test(trimmedValue)) identifiedType = 'notebooklm';
              else if (urlRegex.test(trimmedValue)) identifiedType = 'url';
              else if (trimmedValue.startsWith('{')) {
                try {
                  const parsedJson = JSON.parse(trimmedValue);
                  identifiedType = (parsedJson && typeof parsedJson === 'object' && parsedJson.chartType) ? 'chartJson' : 'prompt';
                } catch (e) {
                  identifiedType = 'prompt';
                }
              } else identifiedType = 'prompt';
            } else if (typeof imageValue === 'object' && imageValue !== null && imageValue.chartType) {
              identifiedType = 'chartObject';
            }

            switch (identifiedType) {
              case 'svg':
                svgCodes.add(imageValue.replace(/\u00A0/g, ' '));
                break;
              case 'notebooklm':
                notebookLmItems.add(imageValue);
                break;
              case 'timestamp':
                timestamps.add(imageValue);
                break;
              case 'chartJson':
                chartJsons.add(imageValue);
                break;
              case 'chartObject':
                try {
                  chartJsons.add(JSON.stringify(imageValue));
                } catch (e) {
                  console.error("chartObjectの文字列化失敗:", imageValue, e);
                }
                break;
              case 'prompt':
                // ★ 修正: fullImageやintegratedContentの場合は角丸にしない
                const shouldRound = !(slide.type === 'fullImage' || slide.type === 'integratedContent' || slide.type === 'integratedTitle' || slide.isSummary);
                if (textPromptsMap.has(imageValue)) {
                    if (!shouldRound) textPromptsMap.set(imageValue, false);
                } else {
                    textPromptsMap.set(imageValue, shouldRound);
                }
                break;
            }
        });
      });


      const timestampsToExtract = Array.from(timestamps);
      const promptsToProcess = Array.from(textPromptsMap.keys());
      const svgsToConvert = Array.from(svgCodes);
      const jsonsToConvert = Array.from(chartJsons);
      console.log(`[generate] 画像要素分類完了: Timestamps=${timestampsToExtract.length}, Prompts=${promptsToProcess.length}, SVGs=${svgsToConvert.length}, Charts=${jsonsToConvert.length}`);

      let allImagesToUpload = [];
      
      const processedToOriginalJsonMap = new Map();
      // ★ 元JSON -> テーマ適用JSON

      // --- グラフJSON -> SVG -> PNG 変換 ---
      if (jsonsToConvert.length > 0) {
        console.log('[generate] --- グラフJSON -> SVG -> PNG 変換開始 ---');
        const graphTheme = settings.graphColorTheme;
        let processedJsonsForServer = jsonsToConvert;

        if (graphTheme !== 'original') {
          console.log(`[generate] グラフテーマ '${graphTheme}' を適用中...`);
          let replacementColor;
          switch (graphTheme) {
            case 'primary':
              replacementColor = settings.primaryColor;
              break;
            case 'gemini':
              replacementColor = "#gemini";
              break;
            case 'night':
              replacementColor = "#night";
              break;
          }
          processedJsonsForServer = jsonsToConvert.map(originalJsonString => {
            try {
              const chartData = JSON.parse(originalJsonString);
              if (chartData && chartData.data) chartData.data.colors = replacementColor;

              const processedJsonString = JSON.stringify(chartData);
              
     
              processedToOriginalJsonMap.set(originalJsonString, processedJsonString);
              
              return processedJsonString;
            } catch (e) {
              console.warn('グラフJSONの色置換失敗。元データ使用:', e.message, originalJsonString.substring(0, 50));
              
              processedToOriginalJsonMap.set(originalJsonString, 
originalJsonString);
              return originalJsonString;
            }
           });
        } else {
          console.log('[generate] グラフテーマ: オリジナルを使用');
          jsonsToConvert.forEach(originalJsonString => processedToOriginalJsonMap.set(originalJsonString, originalJsonString));
        }

        try {
          console.log('[generate] サーバーにSVG変換をリクエスト:', processedJsonsForServer.length, '件');
          const convertedSvgItems = await new Promise((resolve, reject) => {
            google.script.run
              .withSuccessHandler(resolve)
              .withFailureHandler(reject)
              .convertJsonToSvgBatch(processedJsonsForServer);

          });
          console.log('[generate] サーバーからSVG変換結果を受信:', convertedSvgItems.length, '件');

          const animationEnabled = settings.enableGraphAnimation;
          const MAX_ANIMATION_WIDTH = 440;
          // 縮小する最大幅
          
          // ▼▼▼ [修正点 1] 通知を1回にまとめる ▼▼▼
          if (animationEnabled) {
            // ★[新規I] 途中経過トーストは抑制(ざっくり化)
            // showStatus('アニメーショングラフを生成中...', 'info', 10000);
          }
          // ▲▲▲ [修正点 1] ここまで ▲▲▲
          
          const animationFrames = 11;
          // 0% ～ 100% の 11フレーム
          const animationDurationMs = 1500;
          // ▼▼▼ [修正点 3] 最終フレームの待機時間を5秒延長 ▼▼▼
          const animationEndDelayMs = 8500;
          // 3500 + 5000
          // ▲▲▲ [修正点 3] ここまで ▲▲▲

          const frameDelayMs = animationDurationMs / (animationFrames - 1);
          // 1500 / 10 = 150ms
          const delays = [];
          for (let i = 0; i < animationFrames - 1; i++) {
            delays.push(Math.round(frameDelayMs));
            // 10回 (0%...90% 用)
          }
          delays.push(animationEndDelayMs);
          // 11回目 (100% 用)
          
          // ▼▼▼ [修正点 2] 1フレーム目(静止画)用のディレイ追加を削除 ▼▼▼
          // delays.unshift(Math.round(frameDelayMs));
          // <-- この行を削除
          // ▲▲▲ [修正点 2] ここまで (delays は 11 個の配列に戻る) ▲▲▲


          const svgMap = new Map();
          for (const item of convertedSvgItems) {
            if (item.svg) {
              svgMap.set(item.key, item.svg);
              // item.key は テーマ適用済みJSON
            } else {
              console.error(`[generate] グラフJSON -> SVG変換エラー (サーバー): ${item.error}`, item.key.substring(0, 50));
              console.error('[generate] グラフ変換エラー:', item.error); showStatus('グラフの画像化に一部失敗しました（代替表示）', 'warning');
            }
          }

          const chartConversionPromises = jsonsToConvert.map(async (originalJsonString) => {

            const processedJsonString = processedToOriginalJsonMap.get(originalJsonString);

            if (!processedJsonString || !svgMap.has(processedJsonString)) {
              console.warn(`[generate] svgMapにキーが見つかりません (Key: ${processedJsonString ? processedJsonString.substring(0,50) : originalJsonString.substring(0,50)}...)`);
              return null;
    
            }

            // ★ 静止画(100%) SVG
            const svgString = svgMap.get(processedJsonString);
            
            const originalJsonKey = originalJsonString;

            // ★ アニメーションが無効な場合 (静止画パス) ★ (変更なし)
            if (!animationEnabled) {
    
              try {
                 const staticSvg = await renderDynamicSvgInIframe(svgString); 
                const pngData = await convertSvgToBase64Png(staticSvg);
                return {
                  key: originalJsonKey, 
          
                  data: pngData,
                  type: 'chart',
                  nativeSvg: settings.nativeChartMode ? (settings.graphColorTheme === 'primary' ? recolorSvgToPrimary(staticSvg, settings.primaryColor) : staticSvg) : undefined
                };
              } catch (pngError) {
                console.error(`[generate] グラフSVG -> PNG変換エラー (クライアント): ${pngError.message}`, originalJsonString.substring(0, 50));
                console.error('[generate] グラフ画像変換エラー:', pngError); showStatus('グラフの画像化に一部失敗しました（代替表示）', 'warning');
                return null;
              }
            }

            // ★ アニメーションが有効な場合: APNG生成ロジック ★
            console.log(`[generate] アニメーショングラフ生成開始: ${originalJsonString.substring(0, 50)}...`);
            // ▼▼▼ [修正点 1] ループ内の showStatus を削除 ▼▼▼
            // showStatus('アニメーショングラフを生成中...', 'info', 10000);
            // <-- 削除
            // ▲▲▲ [修正点 1] ここまで ▲▲▲

            try {
              // 1. フレーム用のJSON (11個, 0%...100%) を生成
              const jsonFramesStrings = generateAnimationJsonFrames(processedJsonString, animationFrames);
              // 2. 11個のJSONをGASに投げてSVG (11個) を取得
              console.log(`[generate] アニメーションフレームSVG (${jsonFramesStrings.length}件) をGASにリクエスト...`);
              const frameSvgItems = await new Promise((resolve, reject) => {
                google.script.run
                  .withSuccessHandler(resolve)
                  .withFailureHandler(reject)
                  .convertJsonToSvgBatch(jsonFramesStrings);
              });
              console.log(`[generate] アニメーションフレームSVG (${frameSvgItems.length}件) を受信`);

              // ▼▼▼ [修正点 2] 1フレーム目に静止画(100%)を追加する処理を削除 ▼▼▼
              
              // 2b.
              // 既存のアニメーションフレーム(0%～100%)のPromiseを作成
              console.log(`[generate] PNGフレーム (${frameSvgItems.length}件) を並列変換中...`);
              const animationFramePromises = frameSvgItems.map(item => {
                return (async () => {
                  if (item.svg) {
                    try {
                      const staticSvg = await renderDynamicSvgInIframe(item.svg);
        
                      const pngData = await convertSvgToBase64Png(staticSvg, false, MAX_ANIMATION_WIDTH);
                      return pngData; // Base64文字列を返す
                    } catch (e) {
                      console.warn(`[generate] アニメーションフレームSVGの変換に失敗 (並列): ${e.message}`);
       
                       return null; // 失敗したフレームは null
                    }
                  }
                  return null; // SVG自体がなかった場合
                })();
      
              });
              
              // 2c.
              // 静止画Promise(1枚目)の結合を削除
              const allFramePromises = animationFramePromises;
              // ★ animationFramePromises のみ

              // 2d.
              // 全ての並列処理が終わるのを待つ
              const pngFramesBase64Raw = await Promise.all(allFramePromises);
              // ★
              const pngFramesBase64 = pngFramesBase64Raw.filter(Boolean);
              console.log(`[generate] PNGフレーム並列変換完了: ${pngFramesBase64.length} / ${allFramePromises.length} 件成功`);
              // ▲▲▲ [修正点 2] ここまで ▲▲▲


              if (pngFramesBase64.length === 0) {
                throw new Error('アニメーションフレームが1枚も生成されませんでした。');
              }
              
              // 4. PNG (Base64) -> ArrayBuffer に変換 (変更なし)
              console.log(`[generate] PNGフレーム (${pngFramesBase64.length}件) をArrayBufferに変換中...`);
              const {
                frames,
                width,
                height
              } = await base64FramesToArrayBuffers(pngFramesBase64);
              // 5. APNG生成 (変更なし)
              // (delays 配列は 11 フレーム分に戻っている)
              const finalDelays = (frames.length === delays.length) ?
              delays : delays.slice(0, frames.length);
              
              if (frames.length < delays.length) {
                finalDelays[finalDelays.length - 1] = animationEndDelayMs;
              }
              
              // ★[新規L] アニメグラフの変換形式(APNG/GIF)を #graphAnimFormatSelect で選択
              const __ganimFmt = ($('graphAnimFormatSelect') && $('graphAnimFormatSelect').value === 'gif') ? 'gif' : 'apng';
              console.log(`[generate] ${__ganimFmt.toUpperCase()}エンコード中... (${frames.length}フレーム)`);
              const apngBase64 = (__ganimFmt === 'gif')
                ? await createGIFFromFrames(frames, finalDelays, width, height)
                : await createAPNGFromFrames(frames, finalDelays, width, height);

              // 6. 成功 (変更なし)
              return {
                key: originalJsonKey,
                data: apngBase64,
                type: 'animation'
              };
            } catch (animError) {
              console.error(`[generate] アニメーショングラフ生成エラー: ${animError.message}`, originalJsonString.substring(0, 50));
              showStatus('グラフの画像化に一部失敗しました（代替表示）', 'warning');
              // フォールバック: 静止画 (変更なし)
              try {
                console.warn('[generate] アニメーション失敗のため、静止画グラフにフォールバックします。');
                const staticSvg = await renderDynamicSvgInIframe(svgString); 
                const pngData = await convertSvgToBase64Png(staticSvg);
                return {
                  key: originalJsonKey, 
                  data: pngData,
                  type: 'chart',
                  nativeSvg: settings.nativeChartMode ? (settings.graphColorTheme === 'primary' ? recolorSvgToPrimary(staticSvg, settings.primaryColor) : staticSvg) : undefined
                };
              } catch (fallbackError) {
                return null;
              }
            }
          });
          const convertedChartImages = (await Promise.all(chartConversionPromises)).filter(Boolean);
          allImagesToUpload.push(...convertedChartImages);
          console.log(`[generate] グラフJSON -> PNG/APNG 変換完了: ${convertedChartImages.length}件成功`);
        } catch (e) {
          console.error('[generate] グラフJSON -> SVG変換サーバー呼び出しエラー:', e);
          throw new Error('グラフJSONからSVGへの変換中にエラーが発生しました: ' + (e.message || e));
        }
      } else {
        console.log('[generate] グラフJSONなし');
      }

      // --- 既存SVGコード -> PNG 変換 (変更なし) ---
      if (svgsToConvert.length > 0) {
        console.log('[generate] --- 既存SVG -> PNG 変換開始 ---', svgsToConvert.length, '件');
        try {
          const conversionPromises = svgsToConvert.map(async (dynamicSvg) => {
            try {
              const staticSvg = await renderDynamicSvgInIframe(dynamicSvg);


              // ▼▼▼ [変更] フィルタ条件の判定 ▼▼▼
        let filterColor = null;
        // 設定が「プライマリ」の場合のみ、現在のプライマリカラーを渡す
        if (settings.graphColorTheme === 'primary') {
           filterColor = settings.primaryColor; 
        }
        
        // 第4引数に filterColor を渡す
        const pngData = await convertSvgToBase64Png(staticSvg, true, null, filterColor);
        // ▲▲▲ [変更] ここまで ▲▲▲
              return {
                key: dynamicSvg,
  
                data: pngData,
                type: 'svg_code',
                nativeSvg: settings.nativeChartMode ? (settings.graphColorTheme === 'primary' ? recolorSvgToPrimary(staticSvg, settings.primaryColor) : staticSvg) : undefined
              };
            } catch (pngError) {
              console.error(`[generate] SVG -> 
PNG変換エラー: ${pngError.message}`, dynamicSvg.substring(0, 50));
              console.error('SVG画像変換エラー:', pngError); showStatus('図形の画像化に一部失敗しました（代替表示）', 'warning');

   
              return null;
            }
          });
          const convertedImages = (await Promise.all(conversionPromises)).filter(Boolean);
          allImagesToUpload.push(...convertedImages);
          console.log(`[generate] 既存SVG -> PNG 変換完了: ${convertedImages.length}件成功`);
        } catch (e) {
          console.error('[generate] SVG -> PNG変換プロセス全体のエラー:', e);
          throw new Error('SVGからPNGへの変換中にエラーが発生しました: ' + (e.message || e));
        }
      } else {
        console.log('[generate] 既存SVGなし');
      }

      // --- テキストプロンプト -> 画像指定モーダル表示 -> 角丸加工 (変更なし) ---
      if (textPromptsMap.size > 0) {
        console.log('[generate] --- テキストプロンプト -> 画像処理開始 ---', promptsToProcess.length, '件');
        for (const [prompt, shouldRound] of textPromptsMap) {
          try {
            showStatus(`「${prompt}」の画像を指定してください...`, 'info', 10000);
            console.log(`[generate] 画像プロンプト表示: "${prompt}"`);
            const originalImageData = await promptForImage(prompt);
            console.log(`[generate] 画像取得成功: "${prompt}"`);


            let finalImageData = originalImageData;
            if (shouldRound) {
                //showStatus(`画像を角丸加工中... (${prompt})`, 'info', 3000);
                finalImageData = await applyRoundedCorners(originalImageData);
                console.log(`[generate] 角丸加工成功: "${prompt}"`);
            } else {
                console.log(`[generate] 角丸加工スキップ (FullImage): "${prompt}"`);
            }



            allImagesToUpload.push({
              key: prompt,
              data: finalImageData,
              type: 'prompt'
            });
          } catch (promptError) {
            // ▼▼▼【ここから変更】▼▼▼
            if (promptError.message.includes('キャンセル')) { // 従来の「キャンセル」
              showStatus('画像指定を中止しました（生成を停止しました）', 'info');
              console.warn('[generate] 画像プロンプトキャンセル:', prompt);
              generateBtn.disabled = false;
              return;
            } else if (promptError.message.includes('なしで続行')) { // ★ 新しい「なしで続行」
              showStatus(`画像処理をスキップしました。`, 'info'); // ★ infoトースト
              console.log(`[generate] 画像がスキップされました: "${prompt}"`);
              // ここでは return しない (ループが続行)
            } else { // その他の予期せぬエラー
              console.error(`[generate] プロンプト画像処理エラー (${prompt}):`, promptError);
              showStatus(`画像処理エラー: ${promptError.message}`, 'error');
            }
            // ▲▲▲【変更ここまで】▲▲▲
          }
        }
        console.log('[generate] テキストプロンプト -> 画像処理完了');
      } else {
        console.log('[generate] テキストプロンプトなし');
      }



      // ▼▼▼ [追加] ここにNotebookLMの処理ブロックを挿入 ▼▼▼

      // --- NotebookLM (PDFページ指定) 処理 ---
      const notebookLmList = Array.from(notebookLmItems);
      if (notebookLmList.length > 0) {
        console.log('[generate] --- NotebookLM PDF処理開始 ---', notebookLmList.length, '件');
        // ファイル名のヒントとして1ページ目のタイトルを取得
        const presentationTitle = (slideData[0] && slideData[0].title) ? slideData[0].title : "プレゼンテーション";
        
        for (const itemKey of notebookLmList) {
          try {
            // ページ番号の抽出
            const match = itemKey.match(notebookLmRegex);
            if (!match) continue;
            
            const pageNum = parseInt(match[1], 10);
            
            // モーダル表示＆画像取得 (オブジェクト {data, layout} が返る)
            const result = await promptForPdfPage(pageNum, presentationTitle);
            
            if (result.type === 'split_layout') {
                const splitData = result.data;
                
                // 1. 背景画像を登録
                const bgKey = itemKey + "_bg";
                allImagesToUpload.push({
                    key: bgKey,
                    data: splitData.background,
                    type: 'split_bg'
                });

                // 2. 分割レイヤーをすべて登録
                const layerData = splitData.layers.map((layer, idx) => {
                    const layerKey = itemKey + `_layer_${idx}`;
                    allImagesToUpload.push({
                        key: layerKey,
                        data: layer.dataBase64,
                        type: 'split_layer'
                    });
                    // 座標とサイズを保持 (Canvas座標系)
                    return {
                        key: layerKey,
                        x: layer.x,
                        y: layer.y,
                        w: layer.width,
                        h: layer.height
                    };
                });

                // 3. データを書き換え (GAS側で認識できる構造にする)
                // ★修正: slideData全体から、該当するキーを持つスライドを探して更新する
                slideData.forEach(s => {
                    // image, backgroundImage, foregroundImage のいずれかが itemKey と一致するか確認
                    if (s.image === itemKey || s.backgroundImage === itemKey || s.foregroundImage === itemKey) {
                        s.type = 'splitImage'; // 新しいスライドタイプ
                        s.image = {
                            backgroundImage: bgKey,
                            layers: layerData,
                            originalWidth: splitData.originalWidth,
                            originalHeight: splitData.originalHeight
                        };
                    }
                });
                
                console.log(`[generate] Split Layout processed: ${splitData.layers.length} layers.`);
                continue; // 次のアイテムへ
            }

            let finalImageData = result.data;
            const layoutType = result.layout; // 'pdf_layout' or 'standard'

            // ★ PDFレイアウト以外の場合のみ角丸加工を行う
            if (layoutType !== 'pdf_layout' && layoutType !== 'full_layout_with_margins') {
                finalImageData = await applyRoundedCorners(finalImageData);
            } else {
                console.log(`[generate] PDFレイアウトのため角丸加工をスキップ: ${itemKey}`);
            }

            allImagesToUpload.push({
              key: itemKey,
              data: finalImageData,
              type: 'notebooklm',
              layout: layoutType // ★ レイアウト情報を保持
            });
          } catch (pdfError) {
             if (pdfError.message.includes('なしで続行')) {
                 showStatus('PDF処理をスキップしました。', 'info');
                 console.log(`[generate] PDF処理スキップ: "${itemKey}"`);
             } else {
                 console.error('[generate] PDF処理エラー:', pdfError);
                 showStatus(`PDF処理エラー: ${pdfError.message}`, 'error');
             }
          }
        }
      }
      // ▲▲▲ [追加] ここまで ▲▲▲


      // --- タイムスタンプ -> 動画選択 -> フレーム抽出 -> 角丸加工 (変更なし) ---
      if (timestampsToExtract.length > 0) {
        console.log('[generate] --- タイムスタンプ -> 動画フレーム処理開始 ---', timestampsToExtract.length, '件');
        let videoFile = null;
        let videoQuality = 480;
        try {
          showStatus('画像切り出しのため、動画ファイルを選択してください。', 'info', 10000);
          console.log('[generate] 動画ファイル選択を要求');

          const videoResult = await promptForVideo();
          if (!videoResult || !videoResult.file) { // キャンセル等のチェック
             // (エラー処理へ)
             // ... (既存コードで return されるはず) ...
          }
          videoFile = videoResult.file;
          videoQuality = videoResult.quality;
          
          if (!videoFile) {
            showStatus('動画選択を中止しました（生成を停止しました）', 'info');
            console.warn('[generate] 動画選択キャンセル');
            generateBtn.disabled = false;
            return;
          }
          console.log('[generate] 動画ファイル選択完了:', videoFile.name);
          showStatus('動画から画像の抽出を開始します...', 'info', 60000);
          console.log('[generate] フレーム抽出開始:', timestampsToExtract);

          const extractedFrames = await extractFrames(videoFile, timestampsToExtract, videoQuality);
          console.log(`[generate] フレーム抽出完了: ${extractedFrames.length}件成功`);

          console.log('[generate] フレーム角丸加工開始...');
          const processingPromises = extractedFrames.map(frame => {
            return (async () => {
              try {
                if (frame.isAnimation) {

                  // ★ アニメーション (APNG) 
                   // の場合
    
                  console.log(`[generate] アニメーション(${frame.timestamp})は角丸処理をスキップします。`);
                  return {
                    key: frame.timestamp,

                    data: frame.data,
                     type: 'animation' // 
                     // ★ type を 'animation' に設定
                  };

                }

                // ★ 静止画の場合
                //showStatus(`画像を角丸加工中... (${frame.timestamp})`, 'info', 3000);
                const
         
                 roundedImageData = await applyRoundedCorners(frame.data);

                return {
                  key: frame.timestamp,
                  data: roundedImageData,


                  type: 'timestamp' // ★ type を 'timestamp' に設定
        
                };
              } catch (roundingError) {
                console.error(`[generate] フレーム角丸加工エラー (${frame.timestamp}):`, roundingError);
                showStatus('一部フレームの加工に失敗しました', 'warning');
                return null;
              }
            })();
          });
          const processedFrames = (await Promise.all(processingPromises)).filter(Boolean);
          console.log(`[generate] フレーム角丸加工完了: ${processedFrames.length}件成功`);
          allImagesToUpload.push(...processedFrames);

        } catch (videoError) {
          if (videoError.message.includes('キャンセル')) { // 従来の「キャンセル」
            console.error('[generate] 動画処理プロセス全体でエラー:', videoError);
            showStatus(`動画処理エラー: ${videoError.message}`, 'error');
            generateBtn.disabled = false;
            return;
          } else if (videoError.message.includes('なしで続行')) { // ★ 新しい「なしで続行」
            showStatus('動画処理をスキップしました。', 'info');
            console.warn('[generate] 動画選択がスキップされました。');
            // ここでは return しない (generate関数が続行)
          } else { // その他の予期せぬエラー
            console.error('[generate] 動画処理プロセス全体でエラー:', videoError);
            showStatus(`動画処理エラー: ${videoError.message}`, 'error');
            generateBtn.disabled = false;
            return;
          }
        }
      } else {
        console.log('[generate] タイムスタンプなし');
      }



      // --- Base64埋め込み & 最終JSON生成 ---
      console.log('[generate] --- Base64埋め込み & 最終JSON生成開始 ---');
      if (allImagesToUpload.length > 0) {
        console.log(`[generate] ${allImagesToUpload.length}件の画像を埋め込み中...`);
        const imageMap = new Map();
        for (const item of allImagesToUpload) imageMap.set(item.key, {
          data: item.data,
          type: item.type,
          layout: item.layout,
          // ★ nativeChartMode: chart/svg_code の静的SVGを imageMap 経由で突合せ→slideData へ伝播(ここで落とすとbackendで noNativeSvg→PNG)。
          nativeSvg: item.nativeSvg
        });

        // ★★★ 修正: 複数のキーに対して埋め込み処理を行う ★★★
        const imageKeys = ['image', 'backgroundImage', 'foregroundImage'];

        slideData.forEach(slide => {
          imageKeys.forEach(key => {

              if (slide.type === 'splitImage' && key === 'image' && slide.image) {
                  const imgObj = slide.image;
                  // 1. 背景画像の置換 (キー -> Base64)
                  if (imgObj.backgroundImage && imageMap.has(imgObj.backgroundImage)) {
                      imgObj.backgroundImage = imageMap.get(imgObj.backgroundImage).data;
                  }
                  // 2. レイヤー画像の置換 (キー -> Base64)
                  if (imgObj.layers && Array.isArray(imgObj.layers)) {
                      imgObj.layers.forEach(layer => {
                          if (layer.key && imageMap.has(layer.key)) {
                              layer.key = imageMap.get(layer.key).data;
                          }
                      });
                  }
                  return; // このキーの処理は完了
              }

              const imageValue = slide[key];
              let imageKey = null;

              if (typeof imageValue === 'string' && imageValue.trim() !== '') {
                const trimmedValue = imageValue.trim();
                if (svgCodes.has(trimmedValue.replace(/\u00A0/g, ' '))) imageKey = trimmedValue.replace(/\u00A0/g, ' ');
                else if (timestamps.has(trimmedValue)) imageKey = trimmedValue;
                else if (notebookLmItems.has(trimmedValue)) imageKey = trimmedValue;
                else if (textPromptsMap.has(trimmedValue)) imageKey = trimmedValue;
                else if (chartJsons.has(trimmedValue)) imageKey = trimmedValue;
              } else if (typeof imageValue === 'object' && imageValue !== null && imageValue.chartType) {
                try {
                  const jsonStringKey = JSON.stringify(imageValue);
                  if (chartJsons.has(jsonStringKey)) imageKey = jsonStringKey;
                } catch (e) { /* ignore */ }
              }

              if (imageKey && imageMap.has(imageKey)) {
                const imageInfo = imageMap.get(imageKey);
                
                // ★★★ 修正: バックアップキーを動的に生成 (_originalImage, _originalBackgroundImage...)
                const backupKey = '_original' + key.charAt(0).toUpperCase() + key.slice(1);
                slide[backupKey] = imageKey;

                // PDFレイアウトの場合のタイプ変更ロジック
                if (imageInfo.type === 'notebooklm' && key === 'image') {
                    if (imageInfo.layout === 'pdf_layout') {
                        // 通常のPDFレイアウト: 画像のみ (fullImage)
                        slide.type = 'fullImage';
                        console.log(`[generate] スライドタイプを fullImage に変更 (PDF Layout): ${imageKey}`);
                    } else if (imageInfo.layout === 'full_layout_with_margins') {
                        // 余白調整レイアウト: 画像 + ヘッダー/フッター (integratedContent)
                        slide.type = 'integratedContent'; 
                        console.log(`[generate] スライドタイプを integratedContent に変更 (With Margins): ${imageKey}`);
                    }
                }



                if (imageInfo.type === 'chart' || imageInfo.type === 'svg_code' || imageInfo.type === 'animation') {
                  // オブジェクトとして格納
                  slide[key] = {
                    "info": imageInfo.type,
                    "data": imageInfo.data,
                    // ★ nativeChartMode: 編集可能図形化のため静的SVGを slideData に載せる(フラグON時のみ存在/undefinedはJSON.stringifyで除外)。
                    "nativeSvg": imageInfo.nativeSvg
                  };
                } else {
                  // Base64文字列として格納
                  slide[key] = imageInfo.data;
                }

              } else if (imageKey) {
                console.warn(`[generate] Base64埋め込み失敗: マップにキーが見つかりません (${key}):`, imageKey.substring(0, 50));
              }
          });
        });
        console.log('[generate] 画像埋め込み完了');
      } else {
        console.log('[generate] 埋め込む画像なし');
      }
      try {
        finalSlideDataString = JSON.stringify(slideData);
        console.log('[generate] 最終JSON文字列生成成功 (最初の200文字):', finalSlideDataString.substring(0, 200));
        // ※エディタ/テキストエリアへの反映は下方の共通ブロックで一括処理する。
        //   ここでの重複反映を撤廃(以前は同じ更新が2回走り、大容量アニメAPNGをエディタが二重デコードしてOOMの一因になっていた)。




      } catch (e) {
        console.error('[generate] 最終JSON文字列化エラー:', e);
        showStatus('元のデータで処理を続行します', 'warning', 6000);
        finalSlideDataString = slideDataString;
      }
    } else {
      console.log('[generate] 画像保持オプションのため、画像分類・処理・埋め込みをスキップ');
      finalSlideDataString = JSON.stringify(slideData);
    }



    // ▼▼▼ [新規追加] 生成されたデータ(Base64画像含む)をエディタに反映する処理 ▼▼▼
    
    // 注意: エディタが開いている場合、先に exitEditorMode を呼ばないと、
    // エディタが保持している「古いデータ」でテキストエリアが上書きされてしまいます。
    
    // ★大容量データ(動画アニメ等)はアプリ内エディタへの反映/巨大textarea更新でOOMするため、閾値超ならスキップ。
    //   GAS送信は finalSlideDataString 変数を使う(下方 generateSlidesFromWebApp)ので、スキップしてもスライドは正常生成される。
    const __genDataLen = finalSlideDataString ? finalSlideDataString.length : 0;
    // ★ 閾値を25MBに引き上げ。従来5MBは分解画像の多いPDF/NotebookLM等で容易に超過し、JSON編集画面に画像が出ず
    //   nanobanana Editorが起動できない原因だった。トースト文言も「アニメ」固定をやめ実サイズ表示に修正。
    if (__genDataLen > 25000000) {
        console.warn('[generate] 生成データが大容量(' + Math.round(__genDataLen / 1048576) + 'MB)のため、アプリ内エディタ反映/テキストエリア更新をスキップします(スライドは正常に生成されます)');
        try { if (typeof showStatus === 'function') showStatus('スライドを生成しました', 'info', 5000); } catch (e) {} // ★[新規I] 内部値(MB)等の詳細はトーストに出さずざっくり化
    } else if (typeof SlideEditor !== 'undefined' && SlideEditor.isEditorActive()) {
        console.log('[generate] エディタの内容を更新されたデータ(画像入り)でリフレッシュします...');
        const savedIndex = SlideEditor.getCurrentIndex(); // 現在見ているページ位置を記憶
        // 1. まずエディタを閉じる
        SlideEditor.exitEditorMode({ skipValidation: true });
        // 2. テキストエリアを「最新のデータ（画像入り）」で上書きする
        $('slideDataInput').value = finalSlideDataString;
        // 3. エディタを再度開き、新しいデータを読み込ませる
        SlideEditor.enterEditorMode();
        // 4. 記憶していたページ位置まで戻す
        if (savedIndex > 0) {
            for (let k = 0; k < savedIndex; k++) {
                const nextBtn = document.getElementById('editorNextBtn');
                if (nextBtn && !nextBtn.disabled) {
                    nextBtn.click();
                }
            }
        }
    } else {
        // エディタが開いていない場合は、テキストエリアのみ更新しておく
        $('slideDataInput').value = finalSlideDataString;
    }
    // ▲▲▲ [新規追加] ここまで ▲▲▲




    // ★[新規Q] HTTP 413回避: 送信データが大容量なら、splitImageのbase64画像をDriveへ先行アップロードし参照に置換してから送る。
    try {
      if (finalSlideDataString && finalSlideDataString.length > 25000000) {
        showStatus('画像をドライブ経由で送信します...', 'info', 30000);
        // ★[B5] finalSlideDataString は直前に slideData から stringify したもので、以降 slideData は未変更。
        //   25MB級文字列の再パース(巨大オブジェクト二重生存=OOMピーク)を避け、既存オブジェクトを直接渡す。
        await _offloadSplitImagesToDrive(slideData);
        finalSlideDataString = JSON.stringify(slideData);
        console.log('[generate][Q] Driveオフロード後サイズ: ' + Math.round(finalSlideDataString.length / 1048576) + 'MB');
      }
    } catch (eOffload) {
      console.error('[generate][Q] Driveオフロード失敗(従来送信を試行):', eOffload);
    }

    /// ▼▼▼ 【ここから置き換え】 サーバー通信と完了処理 (デバッグ版) ▼▼▼
    const actionVerb = isNewPresentation ? '生成' : '更新';
    const loadingToastId = showStatus(`スライドを${actionVerb}しています...`, 'info', 60000);
    
    // ★詳細デバッグログ
    console.log(`%c[Client Debug] --- Server Request Start ---`, 'background: #222; color: #bada55');
    console.log(`[Client Debug] Action: ${actionVerb}`);
    console.log(`[Client Debug] Target ID:`, presentationId); // ここが null か ID か確認
    console.log(`[Client Debug] Settings:`, settings);

    // ★白フラッシュ防止(@48): ネイティブ生成でも生成中は about:blank で白くしない。
    //   以前は「旧デッキが生成中にゴミ箱送りになる→プレビューにゴミ箱モーダルが出る」ため白くしていたが、
    //   バックエンド側で旧デッキのゴミ箱化を“生成完了後”まで遅延するようにした(__nativeOldDeckToTrash)。
    //   そのため生成中は旧デッキ(/edit)を表示し続けられ、完了時に完了処理側で新デッキへ一気に差し替わる。

    // ★[D6] 送信ヘルパー + 413/DOCUMENT_TOO_LARGE 事後フォールバック:
    //   送信が大きすぎて失敗した場合、未オフロードのsplitImage画像をDriveへ退避して1度だけ再送する
    //   (予防オフロード=25MB閾値をすり抜けた/JSON.parse失敗で予防が走らなかったケースの安全網)。
    const __sendToServer = (payload) => new Promise((resolve, reject) => {
      google.script.run
        .withSuccessHandler((response) => {
            console.log(`%c[Client Debug] Server Success!`, 'color: green');
            console.log(`[Client Debug] Returned URL:`, response);
            resolve(response);
        })
        .withFailureHandler((e) => {
            console.error(`%c[Client Debug] Server Failed!`, 'color: red', e);
            reject(e);
        })
        .generateSlidesFromWebApp(payload, settings, presentationId, imageUpdateOption);
    });
    const __isTooLargeError = (e) => {
      const m = (e && (e.message || e.name)) ? String(e.message || e.name) : String(e || '');
      return /413|DOCUMENT_TOO_LARGE|too large|大きすぎ|接続できませんでした/i.test(m);
    };
    let finalPresentationUrl;
    try {
      finalPresentationUrl = await __sendToServer(finalSlideDataString);
    } catch (eSend1) {
      if (!__isTooLargeError(eSend1)) throw eSend1;
      console.warn('[generate][D6] 送信が大きすぎ(413等)。Driveへ退避して1度だけ再送します。');
      showStatus('画像をドライブ経由で再送します...', 'warning', 30000);
      try {
        // ★[B5] 場所1と同じ前提: この時点でも slideData は finalSlideDataString と一致
        //   (場所1オフロード実行済みなら slideData はDrive参照化された同一オブジェクト、未実行なら未変更のまま)。
        //   巨大文字列の再パースを避け既存オブジェクトを直接渡す。_offloadSplitImagesToDrive は冪等
        //   (既に'DRIVE_IMAGE:'化済みなら put() が即return)なので二重オフロードも安全。
        await _offloadSplitImagesToDrive(slideData);
        finalSlideDataString = JSON.stringify(slideData);
        console.log('[generate][D6] 再オフロード後サイズ: ' + Math.round(finalSlideDataString.length / 1048576) + 'MB');
      } catch (eOff2) {
        console.error('[generate][D6] 再オフロードに失敗→元の送信エラーを送出:', eOff2);
        throw eSend1;
      }
      finalPresentationUrl = await __sendToServer(finalSlideDataString); // 1度だけ再送(再失敗はそのままthrow)
    }


    console.log(`[generate] バックエンド処理完了. URL: ${finalPresentationUrl}`);
    if (loadingToastId) {
      const loadingToast = $(loadingToastId);
      if (loadingToast) {
        loadingToast.remove();
        console.log('[generate] 「更新しています」トーストを強制削除しました。');
      }
    }
    
    // (完了処理)
    showStatus(`スライドの${actionVerb}が完了しました！`, 'success');
    
    // ★ 返却URL(=実際に生成されたデッキ)を常に採用する。
    //   通常フローでは先行作成と同一デッキのURLだが、nativeSectionMode では新しいネイティブデッキ(別ID)の
    //   URLになるため、新規生成(isNewPresentation=true)でも返却URLを使う(空デッキURLのまま残さない)。
    if (finalPresentationUrl) {
        lastGeneratedSlideUrl = finalPresentationUrl;
    }
    
    const newTabBtn = $('openSlideNewTabBtn');
    if (newTabBtn) {
      newTabBtn.href = lastGeneratedSlideUrl; // 常に最新のURLを参照
    }

    // ★ 返却デッキが現在のプレビューと別ID(=nativeSectionModeで新ネイティブデッキになった等)の場合は、
    //   iframe を結果デッキに張り替える。通常フローは同一URLのため no-op(再読込なし=ちらつかない)。
    try {
      const __pf = $('slidePreviewFrame');
      if (__pf && lastGeneratedSlideUrl) {
        const __embed = lastGeneratedSlideUrl; // ★編集可能プレビュー(@47): /editのまま
        // ★ 完了時のちらつき防止: 既に表示中のデッキと結果デッキが「同一ID」なら src を触らない
        //   (Slidesの/editは同一IDなら生成中もライブ更新され続けるため再読込不要)。
        //   URL文字列の完全一致比較だと空デッキURL↔結果URLの形式差/ブラウザ正規化で誤って張り替え→
        //   完成間際に一瞬リロードが走っていた。デッキIDで比較し、IDが実際に変わったとき(nativeで別デッキ化等)のみ張り替える。
        const __deckId = function (u) { const m = String(u || '').match(/\/d\/([a-zA-Z0-9_-]+)/); return m ? m[1] : null; };
        const __curId = __deckId(__pf.src);
        const __newId = __deckId(__embed);
        if (__newId && __curId !== __newId) {
          __pf.src = __embed;
          console.log('[generate] プレビューを結果デッキに張り替えました(ID変化): ' + __embed);
        } else {
          console.log('[generate] プレビューは同一デッキのため張り替えスキップ(再読込なし)');
        }
      }
    } catch (e) { console.warn('[generate] プレビュー張り替え失敗(無視):', e); }

    console.log('[generate] プレビュー表示処理開始 (完了処理)');
    const genBtn = $('generateBtn');
    const buttonGroup = $('buttonGroup');
    genBtn.querySelector('.btn-text').textContent = 'スライドを更新';

    let showBtn = $('showLastSlideBtn');

    // ▼▼▼ [修正] ボタンの有無に関わらず、buttonGroupのスタイルを 'flex' に設定 ▼▼▼
    buttonGroup.style.display = 'flex';
buttonGroup.style.gap = '1rem';
    // ▲▲▲ [修正] ここまで ▲▲▲

    if (!showBtn) {
      // (このブロックはもう実行されないはずだが、念のため残す)
      showBtn = document.createElement('button');
      showBtn.id = 'showLastSlideBtn';
      showBtn.type = 'button';
      showBtn.className = 'btn btn-primary';
      buttonGroup.insertBefore(showBtn, genBtn);
      console.log('[generate] 「スライドを表示」ボタンを新規作成しました');
}
    
    // プレビューは既に表示されているはずなので、
    // isNewPresentation=true の場合は showPreviewPane() を *呼び出さない*
    
    if (!isNewPresentation) {
        // 更新 (isNewPresentation=false) の場合のみ、
        // プレビューが非表示なら表示し、表示中ならリロードする
        try {
            if (!isPreviewVisible) {
                console.log('[generate] 更新完了。プレビューペインを表示します');
                showPreviewPane(); // isPreviewVisible が true になる
            } else {
                // ★GoogleスライドのEmbedは自動更新されるため、リロード処理は不要★
                console.log('[generate] 更新完了。プレビューは自動更新されます。');
                // const iframe = $('slidePreviewFrame');
                // if (iframe && lastGeneratedSlideUrl) {
                //     let embedUrl = lastGeneratedSlideUrl.replace("/edit", "/embed");
                //     iframe.src = embedUrl; 
                //     console.log('[generate] プレビュー内容をリロードしました');
                // }
            }
        } catch (e) {
            console.error("[generate] iFrameプレビュー表示エラー:", e);
            showStatus('エラー: プレビュー表示に失敗しました。', 'error');
        }
    } else {
        // 新規作成 (isNewPresentation=true) の場合は、
        // プレビューは既に表示されているので何もしない。(Googleスライドが自動更新する)
        console.log('[generate] 新規作成完了。プレビューは自動更新されます。');
    }

    // 最後に、現在のモード (テキスト or 編集) に応じてボタンの状態を更新
    if (SlideEditor && SlideEditor.isEditorActive()) {
        SlideEditor.updatePreviewButtonForEditor(true);
    } else {
        // テキストモード用の更新
        if (showBtn) {
          showBtn.textContent = 'プレビューを閉じる';
          showBtn.classList.remove('btn-primary');
          showBtn.classList.add('btn-secondary');
          showBtn.style.backgroundColor = 'var(--secondary-color)';
          showBtn.onclick = function() { if (isPreviewVisible) hidePreviewPane(); else showPreviewPane(); };
        }
    }
    
    console.log('[generate] プレビュー表示処理完了');

    // バックグラウンドプリロード開始
    setTimeout(() => {
        startBackgroundPreloading();
    }, 1000); // 描画完了後、少し待ってから開始

  } catch (error) {
    if (!(error && error.message && error.message.includes('キャンセル'))) {
      console.error('[generate] !!! 全体プロセスエラー !!!:', error);
      console.error('[generate] エラー:', error); showStatus('エラーが発生しました', 'error', 8000);
    }
  } finally {
    // ★[要望3] 初回生成の完了(成功/失敗/中断いずれも)で解除→以降「ページ更新」ボタンが出てよい
    window.isInitialGenerating = false;
    // ★[要望4] 排他解除(generateBtn/updatePageBtn を両方とも復帰)
    setSlideJobRunning(false);
    const overlay = $('previewOverlay');
    if (overlay) {
        // 1. 90秒アニメーションを即座に停止（インラインスタイルで上書き）
        overlay.style.animation = 'none';

        // 2. opacity を 0 に設定
        // (animation が none になったため、
        //  #previewOverlay の transition が発動します)
        overlay.style.opacity = '0';

        // 3. 3秒後 (フェードアウト完了後) にクリーンアップ
setTimeout(() => {
            if (overlay) {
                overlay.classList.remove('loading'); // アニメーションクラスを削除
                overlay.style.animation = ''; // (1)のインラインスタイルを解除
                overlay.style.opacity = '';   // (2)のインラインスタイルを解除
                // (これにより、#previewOverlay の display: none が適用されます)
            }
        }, 3000); // 3000ms = 3秒
    }
    
    stopButtonTimer(generateBtn, 'スライドを更新');
    generateBtn.disabled = false;

    const buttonGroup = $('buttonGroup');
    if (buttonGroup) {
      buttonGroup.style.display = 'flex';
      buttonGroup.style.gap = '1rem';
    }

    if (SlideEditor && typeof SlideEditor.updatePreviewButtonForEditor === 'function') {
        // 現在の編集モード状態（isEditorActive()）を渡して、ボタン表示を正しく更新させます
        SlideEditor.updatePreviewButtonForEditor(SlideEditor.isEditorActive());
}
    console.log('[generate] --- スライド生成/更新プロセス終了 ---');
  }
} // --- generate 関数の終わり ---






/**
 * ▼▼▼ [修正版] テキスト抽出エンジン ▼▼▼
 * 修正点:
 * 1. calculateDominantColor の実装 (背景色自動判定)
 * 2. OCR実行時のリトライロジック (パディング切り替え) の復元
 * 3. ハードコードされた白色設定の撤廃
 */
const TextExtractionEngine = (function() {
    
    const PARAMS = {
        threshold: 70,
        splitObjects: true,
        showAnalysis: true,
        sharpenImage: false,
        detectInverted: true,
        keepBackground: false, 
        forceBlackText: true,
        useOriginalForOcr: true
    };

    let currentScheduler = null;
    let schedulerInitPromise = null;

    // --- 内部クラス: UnionFind ---
    class UnionFind {
        constructor(elements) { this.parent = new Map(); elements.forEach(e => this.parent.set(e, e)); }
        find(x) { if (this.parent.get(x) !== x) this.parent.set(x, this.find(this.parent.get(x))); return this.parent.get(x); }
        union(x, y) { let rX = this.find(x), rY = this.find(y); if (rX !== rY) this.parent.set(rX, rY); }
    }

    // --- ★追加: 背景色（ドミナントカラー）計算 ---
    function calculateDominantColor(img) {
        const w = img.width;
        const h = img.height;
        const canvas = document.createElement('canvas');
        canvas.width = w; canvas.height = h;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0);
        
        const imageData = ctx.getImageData(0, 0, w, h);
        const data = imageData.data;
        
        const colorStats = {};
        let maxCount = 0;
        let dominantColor = { r: 255, g: 255, b: 255 };
        const step = 4 * 10; // 間引いて高速化
        
        for (let i = 0; i < data.length; i += step) {
            const r = data[i];
            const g = data[i+1];
            const b = data[i+2];
            const a = data[i+3];
            
            if (a < 128) continue; 
    
            const qR = Math.floor(r / 10) * 10;
            const qG = Math.floor(g / 10) * 10;
            const qB = Math.floor(b / 10) * 10;
            const key = `${qR},${qG},${qB}`;
            
            if (!colorStats[key]) {
                colorStats[key] = { count: 0, rSum: 0, gSum: 0, bSum: 0 };
            }
            colorStats[key].count++;
            colorStats[key].rSum += r;
            colorStats[key].gSum += g;
            colorStats[key].bSum += b;
            
            if (colorStats[key].count > maxCount) {
                maxCount = colorStats[key].count;
            }
        }
        
        for (let key in colorStats) {
            if (colorStats[key].count === maxCount) {
                const stat = colorStats[key];
                dominantColor = {
                    r: Math.round(stat.rSum / stat.count),
                    g: Math.round(stat.gSum / stat.count),
                    b: Math.round(stat.bSum / stat.count)
                };
                break;
            }
        }
        return dominantColor;
    }

    async function initTesseractScheduler() {
        if (currentScheduler) return currentScheduler;
        if (schedulerInitPromise) return schedulerInitPromise;
    
        schedulerInitPromise = (async () => {
            const scheduler = Tesseract.createScheduler();
            const coreCount = navigator.hardwareConcurrency || 4; 
            const workerCount = Math.min(Math.max(2, coreCount), 8); // 安定性重視
            
            console.log(`[TextEngine] Initializing Tesseract with ${workerCount} workers...`);
            
            const workerPromises = [];
            for (let i = 0; i < workerCount; i++) {
                workerPromises.push((async () => {
                    const worker = Tesseract.createWorker();
                    await worker.load();
                    await worker.loadLanguage('eng+jpn');
                    await worker.initialize('eng+jpn');
                    await worker.setParameters({
                        preserve_interword_spaces: '1',
                        tessedit_pageseg_mode: Tesseract.PSM.SINGLE_BLOCK, 
                    });
                    scheduler.addWorker(worker);
                })());
            }
            
            await Promise.all(workerPromises);
            currentScheduler = scheduler;
            return currentScheduler;
        })();
    
        return schedulerInitPromise;
    }

    function applySharpenFilter(ctx, w, h) {
        const imgData = ctx.getImageData(0, 0, w, h);
        const src = imgData.data;
        const output = ctx.createImageData(w, h);
        const dst = output.data;
        const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0];
        for (let y = 1; y < h - 1; y++) {
            for (let x = 1; x < w - 1; x++) {
                let r = 0, g = 0, b = 0;
                for (let ky = -1; ky <= 1; ky++) {
                    for (let kx = -1; kx <= 1; kx++) {
                        const pixelIdx = ((y + ky) * w + (x + kx)) * 4;
                        const weight = kernel[(ky + 1) * 3 + (kx + 1)];
                        r += src[pixelIdx] * weight;
                        g += src[pixelIdx + 1] * weight;
                        b += src[pixelIdx + 2] * weight;
                    }
                }
                const idx = (y * w + x) * 4;
                dst[idx] = Math.min(255, Math.max(0, r));
                dst[idx + 1] = Math.min(255, Math.max(0, g));
                dst[idx + 2] = Math.min(255, Math.max(0, b));
                dst[idx + 3] = src[idx + 3];
            }
        }
        ctx.putImageData(output, 0, 0);
    }

    function findConnectedComponents(width, height, data) {
        const visited = new Uint8Array(width * height);
        const blobs = [];
        const minBlobSize = 50;

        for (let y = 0; y < height; y++) {
            for (let x = 0; x < width; x++) {
                const idx = (y * width + x);
                const pixelIdx = idx * 4;
                if (data[pixelIdx + 3] > 0 && visited[idx] === 0) {
                    const currentBlob = { minX: x, maxX: x, minY: y, maxY: y, pixels: [] };
                    const stack = [idx];
                    visited[idx] = 1;
                    while(stack.length > 0) {
                        const currIdx = stack.pop();
                        const cx = currIdx % width;
                        const cy = Math.floor(currIdx / width);
                        const cPixelIdx = currIdx * 4;
                        if (cx < currentBlob.minX) currentBlob.minX = cx;
                        if (cx > currentBlob.maxX) currentBlob.maxX = cx;
                        if (cy < currentBlob.minY) currentBlob.minY = cy;
                        if (cy > currentBlob.maxY) currentBlob.maxY = cy;
                        currentBlob.pixels.push({ x: cx, y: cy, r: data[cPixelIdx], g: data[cPixelIdx+1], b: data[cPixelIdx+2], a: data[cPixelIdx+3] });
                        
                        const neighbors = [currIdx + 1, currIdx - 1, currIdx + width, currIdx - width];
                        const checks = [cx + 1 < width, cx - 1 >= 0, cy + 1 < height, cy - 1 >= 0];
                        for(let i=0; i<4; i++) {
                            if(checks[i]) {
                                const nIdx = neighbors[i];
                                if(visited[nIdx] === 0 && data[nIdx * 4 + 3] > 0) {
                                    visited[nIdx] = 1;
                                    stack.push(nIdx);
                                }
                            }
                        }
                    }
                    if (currentBlob.pixels.length > minBlobSize) blobs.push(currentBlob);
                }
            }
        }
        return blobs;
    }

    function createFabricObjectFromBlob(blob, origW, origH, totalImagePixels) {
        return new Promise((resolve) => {
            const width = blob.maxX - blob.minX + 1;
            const height = blob.maxY - blob.minY + 1;
            const blobCanvas = document.createElement('canvas');
            blobCanvas.width = width;
            blobCanvas.height = height;
            const blobCtx = blobCanvas.getContext('2d');
            
            let totalR = 0, totalG = 0, totalB = 0;
            let totalSqR = 0, totalSqG = 0, totalSqB = 0;
            let nonBlackPixelCount = 0;
            let highSaturationPixelCount = 0;
            let hueSumCos = 0, hueSumSin = 0, validHueCount = 0;
            let pixelCount = 0;
            const pixelCollection = []; 

            if (blob.isFullImage) {
                 const tempC = document.createElement('canvas');
                 tempC.width = origW; tempC.height = origH;
                 tempC.getContext('2d').putImageData(blob.imageData, 0, 0);
                 blobCtx.drawImage(tempC, blob.minX, blob.minY, width, height, 0, 0, width, height);
                 const d = blobCtx.getImageData(0,0,width,height).data;
                 for(let i=0; i<d.length; i+=4) {
                     if(d[i+3]>0) { processPixel(d[i],d[i+1],d[i+2]); pixelCount++; }
                 }
            } else {
                const imgData = blobCtx.createImageData(width, height);
                const d = imgData.data;
                blob.pixels.forEach(p => {
                    const idx = ((p.y - blob.minY) * width + (p.x - blob.minX)) * 4;
                    d[idx]=p.r; d[idx+1]=p.g; d[idx+2]=p.b; d[idx+3]=p.a;
                    processPixel(p.r, p.g, p.b);
                    pixelCount++;
                });
                blobCtx.putImageData(imgData, 0, 0);
            }

            function processPixel(r, g, b) {
                totalR+=r; totalG+=g; totalB+=b;
                totalSqR+=r*r; totalSqG+=g*g; totalSqB+=b*b;
                pixelCollection.push({ r: r, g: g, b: b, brightness: r + g + b });

                const avg = (r+g+b)/3;
                const max = Math.max(r,g,b), min = Math.min(r,g,b);
                const sat = max-min;
                
                if(avg > 180 || (avg > 70 && sat > 40)) nonBlackPixelCount++;
                if(sat > 50 && avg > 70) highSaturationPixelCount++;
                
                if(sat>20 && max>30) {
                    let h = 0; const d = max-min;
                    if(d!==0) {
                        if(max===r) h=(g-b)/d+(g<b?6:0);
                        else if(max===g) h=(b-r)/d+2;
                        else h=(r-g)/d+4;
                        h/=6;
                    }
                    const angle = h * Math.PI * 2;
                    hueSumCos += Math.cos(angle);
                    hueSumSin += Math.sin(angle);
                    validHueCount++;
                }
            }

            let isBlackish = false, isColorChar = false, exclusionReason = null, inclusionReason = null;
            let finalColor = { r: 0, g: 0, b: 0 };
            let statsData = {};

            if (pixelCount > 0) {
                const nonBlackRatio = nonBlackPixelCount / pixelCount;
                const highSaturationRatio = highSaturationPixelCount / pixelCount;
                const meanR = totalR/pixelCount, meanG = totalG/pixelCount, meanB = totalB/pixelCount;
                const mean = (meanR+meanG+meanB)/3;
                
                const varR = (totalSqR/pixelCount)-(meanR*meanR);
                const varG = (totalSqG/pixelCount)-(meanG*meanG);
                const varB = (totalSqB/pixelCount)-(meanB*meanB);
                const maxStdDev = Math.sqrt(Math.max(0, Math.max(varR, varG, varB)));
                
                let hueVariance = 1.0;
                if (validHueCount > 0) {
                    const r = Math.sqrt((hueSumCos/validHueCount)**2 + (hueSumSin/validHueCount)**2);
                    hueVariance = 1 - r;
                } else { hueVariance = 0; }
                
                statsData = { nonBlackRatio, highSaturationRatio, meanBrightness: mean, maxStdDev, hueVariance };

                pixelCollection.sort((a, b) => a.brightness - b.brightness);
                const coreSampleCount = Math.max(5, Math.floor(pixelCollection.length * 0.20));
                const corePixels = pixelCollection.slice(0, coreSampleCount);
                let sumCoreR = 0, sumCoreG = 0, sumCoreB = 0;
                corePixels.forEach(p => { sumCoreR += p.r; sumCoreG += p.g; sumCoreB += p.b; });
                finalColor = {
                    r: Math.floor(sumCoreR / corePixels.length),
                    g: Math.floor(sumCoreG / corePixels.length),
                    b: Math.floor(sumCoreB / corePixels.length)
                };

                if (nonBlackRatio < 0.3 && highSaturationRatio < 0.02) {
                    isBlackish = true;
                    inclusionReason = `黒に近い単色です (黒ピクセル率: ${Math.round((1-nonBlackRatio)*100)}%)`;
                } else {
                    if (nonBlackRatio >= 0.3) exclusionReason = `黒以外の色が多すぎます (黒率: ${Math.round((1-nonBlackRatio)*100)}%)`;
                    else exclusionReason = `鮮やかな色が混入しています`;
                }

                if (!isBlackish) {
                    const aspect = width / height;
                    const isSmallObject = (width < 10 || height < 10 || pixelCount < 200);
                    const isMonochromatic = (maxStdDev < 60 && hueVariance < 0.15) || isSmallObject;
                    const isDarkEnough = mean < 240;
                    const isNotExtreme = aspect > 0.15 && aspect < 6.0;

                    if (isMonochromatic && isDarkEnough && isNotExtreme) {
                        isColorChar = true;
                        inclusionReason = `同系色のカラー文字です (色相分散: ${hueVariance.toFixed(2)})`;
                        exclusionReason = null;
                    } else {
                        if (!isNotExtreme) exclusionReason = `形状が極端です (縦横比: ${aspect.toFixed(2)})`;
                        else if (!isDarkEnough) exclusionReason = `色が薄すぎます (輝度: ${mean.toFixed(0)} >= 基準: 240)`;
                        else exclusionReason = `色のばらつき過多 (偏差: ${maxStdDev.toFixed(1)} >= 60 / 色相分散: ${hueVariance.toFixed(2)} >= 0.15)`;
                    }
                }
            } else { exclusionReason = "ピクセル情報なし"; }

            if (pixelCount < 50) { 
                isBlackish = false; 
                isColorChar = false; 
                exclusionReason = `サイズ過小 (面積: ${pixelCount}px² < 基準: 50px²)`; 
            }
            
            fabric.Image.fromURL(blobCanvas.toDataURL(), function(oImg) {
                // スケールは後で調整するため1のまま
                oImg.set({
                    scaleX: 1, scaleY: 1,
                    left: blob.minX + width/2,
                    top: blob.minY + height/2,
                    originX: 'center', originY: 'center',
                    hasControls: false,
                    hasBorders: false,
                    lockMovementX: true,
                    lockMovementY: true,
                    hoverCursor: 'pointer'
                });

                oImg.isBlackish = isBlackish;
                oImg.isColorChar = isColorChar;
                oImg.exclusionReason = exclusionReason;
                oImg.inclusionReason = inclusionReason;
                oImg.pixelCount = pixelCount;
                oImg.stats = statsData;
                oImg.avgColor = finalColor;
                oImg.originalRect = { x: blob.minX, y: blob.minY, w: width, h: height };
                oImg.originalImageHeight = origH;

                resolve(oImg);
            });
        });
    }

    function getContentBounds(groupOrObj) {
        if (groupOrObj.type !== 'group') {
            return { width: groupOrObj.width * groupOrObj.scaleX, height: groupOrObj.height * groupOrObj.scaleY };
        }
        const objects = groupOrObj.getObjects();
        const gSX = groupOrObj.scaleX;
        const gSY = groupOrObj.scaleY;
        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
        let hasContent = false;
        objects.forEach(o => {
            if (!o.isVisualFrame) {
                hasContent = true;
                const oW = o.width * o.scaleX;
                const oH = o.height * o.scaleY;
                const oL = o.left - oW / 2;
                const oT = o.top - oH / 2;
                if (oL < minX) minX = oL;
                if (oT < minY) minY = oT;
                if (oL + oW > maxX) maxX = oL + oW;
                if (oT + oH > maxY) maxY = oT + oH;
            }
        });
        if (!hasContent) return { width: groupOrObj.width * gSX, height: groupOrObj.height * gSY };
        return { width: (maxX - minX) * gSX, height: (maxY - minY) * gSY };
    }
    
    function getBounds(obj) {
        const w = obj.width * obj.scaleX;
        const h = obj.height * obj.scaleY;
        return { left: obj.left - w/2, top: obj.top - h/2, right: obj.left + w/2, bottom: obj.top + h/2, width: w, height: h };
    }

    function calculateFillRateForObj(obj) {
        return new Promise((resolve) => {
            const objW = obj.width * obj.scaleX;
            const objH = obj.height * obj.scaleY;
    
            if (objW <= 0 || objH <= 0) {
                resolve(0);
                return;
            }
    
            obj.clone((clonedForCalc) => {
                if (clonedForCalc.type === 'group') {
                    clonedForCalc.getObjects().forEach(o => o.visible = true);
                } else {
                    clonedForCalc.visible = true;
                }
    
                const dataURL = clonedForCalc.toDataURL({
                    format: 'png',
                    multiplier: 1
                });
    
                const tempImg = new Image();
                tempImg.onload = function() {
                    const imgWidth = tempImg.width;
                    const imgHeight = tempImg.height;
    
                    if (imgWidth > 0 && imgHeight > 0) {
                        const marginX = imgWidth * 0.05;
                        const marginY = imgHeight * 0.05;
                        const innerW = Math.floor(imgWidth - marginX * 2);
                        const innerH = Math.floor(imgHeight - marginY * 2);
                        const offsetX = Math.floor(marginX);
                        const offsetY = Math.floor(marginY);
    
                        if (innerW > 0 && innerH > 0) {
                            const c = document.createElement('canvas');
                            c.width = innerW;
                            c.height = innerH;
                            const ctx = c.getContext('2d');
                            ctx.drawImage(tempImg, offsetX, offsetY, innerW, innerH, 0, 0, innerW, innerH);
    
                            const imageData = ctx.getImageData(0, 0, innerW, innerH);
                            const data = imageData.data;
                            let nonTransparentPixels = 0;
    
                            for (let i = 0; i < data.length; i += 4) {
                                if (data[i + 3] > 0) {
                                    nonTransparentPixels++;
                                }
                            }
    
                            const area = innerW * innerH;
                            const rate = (nonTransparentPixels / area * 100);
                            resolve(rate);
                        } else {
                            resolve(0);
                        }
                    } else {
                        resolve(0);
                    }
                };
                tempImg.onerror = () => resolve(0);
                tempImg.src = dataURL;
            });
        });
    }

    async function checkAndFilterByFillRate(objects, onProgress) {
        const DEFAULT_THRESHOLD = 35.0; 
        const BLACK_TEXT_THRESHOLD = 26.0; 
        
        if (onProgress) onProgress("オブジェクトの充填率を確認しています...");
    
        const checkPromises = objects.map(async (obj) => {
            if (!obj._isOcrCandidate) return;
            if (obj.exclusionReason && !obj.detectedText) return;
    
            const rate = await calculateFillRateForObj(obj);
            obj.calculatedFillRate = rate;
    
            if (rate !== null) {
                const threshold = obj.isBlackish ? BLACK_TEXT_THRESHOLD : DEFAULT_THRESHOLD;
    
                if (rate < threshold) {
                    stripVisualAids(obj, `充填率不足 (${rate.toFixed(1)}% < 基準: ${threshold}%) - 内側90%判定`);
                }
                else if (obj.isRescuedDarkGroup) {
                    const currentW = obj.width * obj.scaleX;
                    const currentH = obj.height * obj.scaleY;
                    const isWide = currentW >= (currentH * 2);
                    const iconThreshold = isWide ? 95.5 : 93.0;
                    
                    if (rate > iconThreshold) {
                        stripVisualAids(obj, `アイコン判定 (暗色救済 & 高充填率: ${rate.toFixed(1)}% > ${iconThreshold}%)`);
                    }
                }
            }
        });
    
        await Promise.all(checkPromises);
    }

    function groupAndAddObjects(objects, fCanvas) {
        const SAFETY_THRESHOLD = 10.0;
        
        objects.forEach(obj => {
            if(obj.isBackground) return;
            const area = obj.originalRect.w * obj.originalRect.h;
            const density = area > 0 ? (obj.pixelCount / area) * 100 : 0;
            const solidity = area > 0 ? (obj.pixelCount / area) : 0;
            const aspect = (obj.width * obj.scaleX) / (obj.height * obj.scaleY);

            if(density <= SAFETY_THRESHOLD) {
                obj.isBlackish = false; obj.isColorChar = false;
                obj.exclusionReason = `密度不足 (${density.toFixed(1)}% < 基準: ${SAFETY_THRESHOLD}%)`;
            }
            if (aspect > 10.0 || aspect < 0.1) {
                obj.isBlackish = false; 
                obj.isColorChar = false;
                const ratioStr = aspect > 1 ? aspect.toFixed(1) : (1/aspect).toFixed(1);
                obj.exclusionReason = `形状除外 (アスペクト比 1:${ratioStr}) - 線/グラフ判定`;
            }
            if (obj.isColorChar && !obj.isBlackish) {
                const c = obj.avgColor;
                const brightness = (c.r + c.g + c.b) / 3;
                const isSolidSquare = (aspect > 0.8 && aspect < 1.2) && (solidity > 0.90);
                if (brightness <= 120) {
                    if (!isSolidSquare) {
                        obj.isBlackish = true;
                        obj.isColorChar = false;
                        obj.isRescuedDark = true;
                    } else {
                        obj.isColorChar = false;
                    }
                }
            }
        });

        const blackish = objects.filter(o => o.isBlackish);
        const colorChar = objects.filter(o => o.isColorChar);
        const others = objects.filter(o => !o.isBlackish && !o.isColorChar);

        const ufBlack = new UnionFind(blackish);
        groupingProcess(blackish, ufBlack); 
        
        const ufColor = new UnionFind(colorChar);
        groupingProcess(colorChar, ufColor);

        const blackGroups = generateGroups(blackish, ufBlack, 'red');
        const colorGroups = generateGroups(colorChar, ufColor, 'blue');
        
        const allCandidates = [...blackGroups, ...colorGroups, ...others];

        const filterGroups = (groups) => {
            for (const item of groups) {
                if (!item._isOcrCandidate) continue;
                if (item._originalMemberCount !== 1) continue;

                const contentBounds = getContentBounds(item);
                const w = Math.round(contentBounds.width);
                const h = Math.round(contentBounds.height);
                
                // アイコン判定 (カラーまたは救済された単体オブジェクトで、一定サイズ以上のもの)
                const isTargetForIconCheck = (!item.isBlackish) || (item.isRescuedDarkGroup);
                // ※ ocrOriginalImage へのアクセスはここでは行わない（サイズpxのみで判定）
                
                if (w >= 140 || h >= 140) {
                    stripVisualAids(item, `サイズ超過 (W:${w}px, H:${h}px) - イラスト判定`);
                    continue;
                }
                
                if (w >= 10 && h >= 10) continue; 

                const bounds = getBounds(item);
                let isContained = false;
                const cx = item.left;
                const cy = item.top;
    
                for (const other of allCandidates) {
                    if (item === other) continue;
                    if (other.isBackground) continue;
                    const otherBounds = getBounds(other);
                    if (otherBounds.width <= bounds.width || otherBounds.height <= bounds.height) continue;
                    
                    if (cx >= otherBounds.left && cx <= otherBounds.right &&
                        cy >= otherBounds.top && cy <= otherBounds.bottom) {
                        isContained = true;
                        break;
                    }
                }
    
                if (isContained) {
                    const b = getBounds(item);
                    stripVisualAids(item, `微細ノイズ (他要素に内包 / W:${Math.round(b.width)}px H:${Math.round(b.height)}px)`);
                }
            }
        };

        filterGroups(blackGroups);
        filterGroups(colorGroups);

        [...others, ...blackGroups, ...colorGroups].forEach(obj => fCanvas.add(obj));
    }

    function groupingProcess(targets, uf) {
        if(targets.length===0) return;
        const limitH = targets[0].originalImageHeight * 0.15;
        const groupBounds = new Map();
        targets.forEach(o => groupBounds.set(o, {minY: o.originalRect.y, maxY: o.originalRect.y + o.originalRect.h}));

        const getRect = (o) => ({
            l: o.left - o.width/2, t: o.top - o.height/2,
            r: o.left + o.width/2, b: o.top + o.height/2
        });
        const isContained = (inR, outR) => (inR.l >= outR.l && inR.t >= outR.t && inR.r <= outR.r && inR.b <= outR.b);

        for(let i=0; i<targets.length; i++) {
            for(let j=i+1; j<targets.length; j++) {
                const o1 = targets[i], o2 = targets[j];
                if(isContained(getRect(o1), getRect(o2)) || isContained(getRect(o2), getRect(o1))) continue;

                const c1 = o1.getCenterPoint(), c2 = o2.getCenterPoint();
                const h1 = o1.height*o1.scaleY, h2 = o2.height*o2.scaleY;
                const avgH = (h1+h2)/2;
                
                const yDiff = Math.abs(c1.y - c2.y);
                const xDist = Math.abs(c1.x - c2.x);
                const wSum = (o1.width*o1.scaleX + o2.width*o2.scaleX)/2;
                
                const isV = yDiff < avgH * 0.8;
                const isH = (xDist - wSum) <= avgH * 2.0;
                
                const colorDist = Math.sqrt((o1.avgColor.r-o2.avgColor.r)**2 + (o1.avgColor.g-o2.avgColor.g)**2 + (o1.avgColor.b-o2.avgColor.b)**2);

                let isGeometryConsistent = true;
                const isRescued1 = o1.isRescuedDark, isRescued2 = o2.isRescuedDark;
                if (isRescued1 !== isRescued2) {
                    if (Math.abs(o1.top - o2.top) > avgH * 0.2) isGeometryConsistent = false;
                }

                if(isV && isH && colorDist < 60 && isGeometryConsistent) {
                    const r1 = uf.find(o1), r2 = uf.find(o2);
                    if(r1 !== r2) {
                        const b1 = groupBounds.get(r1), b2 = groupBounds.get(r2);
                        const newH = Math.max(b1.maxY, b2.maxY) - Math.min(b1.minY, b2.minY);
                        if(newH <= limitH) {
                            uf.union(o1, o2);
                            const newRoot = uf.find(o1);
                            groupBounds.set(newRoot, {minY: Math.min(b1.minY, b2.minY), maxY: Math.max(b1.maxY, b2.maxY)});
                        }
                    }
                }
            }
        }
    }

    function generateGroups(targets, uf, colorType) {
        const groups = new Map();
        targets.forEach(o => {
            const root = uf.find(o);
            if(!groups.has(root)) groups.set(root, []);
            groups.get(root).push(o);
        });

        const resultList = [];
        const getRect = (o) => ({
              l: o.left - o.width*o.scaleX/2, t: o.top - o.height*o.scaleY/2,
              r: o.left + o.width*o.scaleX/2, b: o.top + o.height*o.scaleY/2
        });
        const isContained = (inR, outR) => (inR.l >= outR.l && inR.t >= outR.t && inR.r <= outR.r && inR.b <= outR.b);
        
        let dynamicStrokeWidth = 2;
        let dynamicFontSize = 14;

        const createAndPushGroup = (membersList, isRescuedObjOverride = null) => {
              if (membersList.length === 0) return;
      
              let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
              const hasRescuedObj = (isRescuedObjOverride !== null) 
                                    ? isRescuedObjOverride 
                                    : membersList.some(m => m.isRescuedDark);
      
              const contentMembers = membersList.filter(m => !m.isVisualFrame);
              
              if (contentMembers.length >= 2) {
                  contentMembers.sort((a, b) => a.left - b.left);
                  const totalH = contentMembers.reduce((sum, m) => sum + (m.height * m.scaleY), 0);
                  const avgH = totalH / contentMembers.length;
      
                  for (let i = 0; i < contentMembers.length - 1; i++) {
                      const curr = contentMembers[i];
                      const next = contentMembers[i+1];
                      const currRight = curr.left + (curr.width * curr.scaleX) / 2;
                      const nextLeft = next.left - (next.width * next.scaleX) / 2;
                      const gap = nextLeft - currRight;
      
                      if (gap >= avgH) {
                          const currTop = curr.top - (curr.height * curr.scaleY) / 2;
                          const nextTop = next.top - (next.height * next.scaleY) / 2;
                          const minTop = Math.min(currTop, nextTop);
      
                          const star = new fabric.Text('★', {
                              left: currRight + gap / 2,
                              top: minTop - (dynamicStrokeWidth * 2),
                              fontSize: dynamicFontSize,
                              fill: '#fbbf24', 
                              stroke: '#b45309',
                              strokeWidth: Math.max(1, dynamicStrokeWidth / 4),
                              originX: 'center', 
                              originY: 'bottom',
                              selectable: false, evented: false,
                              isVisualFrame: true, isStar: true,
                              starReason: `内部空隙警告: 幅${Math.round(gap)}px (基準高さ${Math.round(avgH)}px)`
                          });
                          membersList.push(star);
                      }
                  }
              }
      
              membersList.forEach(o => {
                  if (o.isVisualFrame) return;
                  const oL = o.left - o.width*o.scaleX / 2;
                  const oT = o.top - o.height*o.scaleY / 2;
                  if (oL < gMinX) gMinX = oL;
                  if (oT < gMinY) gMinY = oT;
                  if (oL + o.width*o.scaleX > gMaxX) gMaxX = oL + o.width*o.scaleX;
                  if (oT + o.height*o.scaleY > gMaxY) gMaxY = oT + o.height*o.scaleY;
              });
              
              const gW = gMaxX - gMinX;
              const gH = gMaxY - gMinY;
              const gCX = gMinX + gW / 2;
              const gCY = gMinY + gH / 2;
      
              let strokeColor;
              if (hasRescuedObj) {
                  strokeColor = '#059669'; 
              } else {
                  strokeColor = colorType === 'red' ? 'red' : 'blue';
              }
      
              const frame = new fabric.Rect({
                  left: gCX, top: gCY,
                  width: gW, height: gH, 
                  originX: 'center', originY: 'center',
                  fill: 'transparent',
                  stroke: strokeColor,
                  strokeWidth: hasRescuedObj ? (dynamicStrokeWidth * 1.5) : dynamicStrokeWidth,
                  selectable: false, evented: false,
                  isVisualFrame: true
              });
              membersList.push(frame);
      
              const group = new fabric.Group(membersList, {
                  originX: 'center', originY: 'center',
                  subTargetCheck: true,
                  interactive: true,
                  fill: 'transparent',
                  hasControls: false,
                  hasBorders: false,
                  lockMovementX: true,
                  lockMovementY: true,
                  hoverCursor: 'pointer',
                  objectCaching: false 
              });
      
              group._isOcrCandidate = true;
              group.isBlackish = (colorType === 'red');
              group.isColorChar = (colorType === 'blue');
              group.isRescuedDarkGroup = hasRescuedObj;
      
              const firstContent = membersList.find(m => !m.isVisualFrame) || membersList[0];
              group.inclusionReason = firstContent ? firstContent.inclusionReason : "条件合致"; 
              group._originalMemberCount = membersList.filter(m => !m.isVisualFrame).length; 
              
              resultList.push(group);
          };

        groups.forEach(originalMembers => {
              let members = [...originalMembers];
      
              if(colorType === 'blue' && members.length <= 2) {
                  let maxH = 0;
                  let maxW = 0;
                  members.forEach(o => {
                      const h = o.height * o.scaleY;
                      const w = o.width * o.scaleX;
                      if(h > maxH) maxH = h;
                      if(w > maxW) maxW = w;
                  });
      
                  if (maxH < 15 && maxW < 15) {
                      members.forEach(o => { 
                          o.isColorChar = false; 
                          o.exclusionReason = `[カラー判定] 構成数・サイズ不足 (構成:${members.length}個<=2 / 最大:${Math.round(maxW)}x${Math.round(maxH)} < 基準15px)`; 
                      });
                      members.forEach(o => resultList.push(o));
                      return;
                  }
              }
      
              let primaryOutliers = [];
              let containedOutliers = [];
              let normalMembers = [...members];
              let hasSeparation = false;
      
              if (colorType === 'red' && members.length > 1) {
                  let totalH = 0;
                  members.forEach(o => {
                      totalH += (o.height * o.scaleY);
                  });
                  const avgH = totalH / members.length;
                  const starThreshold = avgH * 2.5;
      
                  primaryOutliers = members.filter(o => (o.height * o.scaleY) >= starThreshold);
      
                  if (primaryOutliers.length > 0) {
                      hasSeparation = true;
                      normalMembers = []; 
      
                      members.forEach(o => {
                          if (primaryOutliers.includes(o)) return;
                          let isContainedInParent = false;
                          const oRect = getRect(o);
                          for (const parent of primaryOutliers) {
                              const pRect = getRect(parent);
                              if (isContained(oRect, pRect)) {
                                  isContainedInParent = true;
                                  break;
                              }
                          }
                          if (isContainedInParent) {
                              containedOutliers.push(o);
                          } else {
                              normalMembers.push(o);
                          }
                      });
                  }
              }
      
              if (normalMembers.length >= 2) {
                  normalMembers.sort((a, b) => a.left - b.left);
                  const leftmost = normalMembers[0];
                  const rest = normalMembers.slice(1);
                  const leftmostRight = leftmost.left + (leftmost.width * leftmost.scaleX) / 2;
                  
                  let restMinLeft = Infinity;
                  rest.forEach(m => {
                      const l = m.left - (m.width * m.scaleX) / 2;
                      if (l < restMinLeft) restMinLeft = l;
                  });
                  const gap = restMinLeft - leftmostRight;
                  const leftmostCY = leftmost.top;
      
                  let rMinY = Infinity, rMaxY = -Infinity;
                  let restTotalH = 0;
                  rest.forEach(m => {
                      const h = m.height * m.scaleY;
                      const t = m.top - h/2;
                      const b = m.top + h/2;
                      if(t < rMinY) rMinY = t;
                      if(b > rMaxY) rMaxY = b;
                      restTotalH += h;
                  });
                  const restCY = (rMinY + rMaxY) / 2;
                  const restAvgH = restTotalH / rest.length;
                  const yDiff = Math.abs(leftmostCY - restCY);
      
                  if (gap > 2 && yDiff > (restAvgH * 0.5)) {
                      const star = new fabric.Text('★', {
                          left: leftmost.left - (leftmost.width * leftmost.scaleX / 2), 
                          top: leftmost.top - (leftmost.height * leftmost.scaleY / 2) - (dynamicStrokeWidth * 2),
                          fontSize: dynamicFontSize,
                          fill: '#fbbf24', 
                          stroke: '#b45309',
                          strokeWidth: Math.max(1, dynamicStrokeWidth / 4),
                          originX: 'left', 
                          originY: 'bottom',
                          selectable: false, evented: false,
                          isVisualFrame: true, isStar: true,
                          starReason: `左端位置ズレにより分離: Y差${Math.round(yDiff)}px (基準${Math.round(restAvgH*0.5)}px)`
                      });
                      
                      createAndPushGroup([leftmost, star], leftmost.isRescuedDark);
                      normalMembers.shift(); 
                      hasSeparation = true;
                  }
              }
      
              primaryOutliers.forEach(o => {
                  const currentH = o.height * o.scaleY;
                  const totalH = members.reduce((sum, m) => sum + m.height * m.scaleY, 0);
                  const avgH = totalH / members.length;
      
                  const star = new fabric.Text('★', {
                      left: o.left - (o.width * o.scaleX / 2), 
                      top: o.top - (o.height * o.scaleY / 2) - (dynamicStrokeWidth * 2),
                      fontSize: dynamicFontSize,
                      fill: '#fbbf24', 
                      stroke: '#b45309',
                      strokeWidth: Math.max(1, dynamicStrokeWidth / 4),
                      originX: 'left', 
                      originY: 'bottom',
                      selectable: false, evented: false,
                      isVisualFrame: true, isStar: true,
                      starReason: `高さ突出により分離: ${Math.round(currentH)}px (平均${Math.round(avgH)}pxの${(currentH/avgH).toFixed(1)}倍)`
                  });
                  createAndPushGroup([o, star], o.isRescuedDark);
              });
      
              containedOutliers.forEach(o => {
                  o.inclusionReason += " (突出オブジェクト内包により分離)";
                  createAndPushGroup([o], o.isRescuedDark);
              });
      
              if (normalMembers.length > 0) {
                  if (hasSeparation && normalMembers.length > 1) {
                      const subUf = new UnionFind(normalMembers);
                      groupingProcess(normalMembers, subUf); 
                      const subGroupsMap = new Map();
                      normalMembers.forEach(o => {
                          const root = subUf.find(o);
                          if(!subGroupsMap.has(root)) subGroupsMap.set(root, []);
                          subGroupsMap.get(root).push(o);
                      });
                      subGroupsMap.forEach(subMembers => {
                          createAndPushGroup(subMembers);
                      });
                  } else {
                      createAndPushGroup(normalMembers);
                  }
              }
          });
          return resultList;
    }

    function stripVisualAids(groupOrObj, reason) {
        groupOrObj._isOcrCandidate = false;
        groupOrObj.isBlackish = false;
        groupOrObj.isColorChar = false;
        groupOrObj.exclusionReason = reason;
        
        if(groupOrObj.type === 'group') {
            groupOrObj.getObjects().forEach(o => {
                o.exclusionReason = reason;
                if (o.isVisualFrame || o.isStar) {
                    o.visible = false;
                    o.opacity = 0;
                }
            });
            groupOrObj.dirty = true;
        }
    }

    async function splitGroupsAtStarsAndRefactor(groups, fCanvas, originalImage) {
        const newGroupsToAdd = [];
        let dynamicStrokeWidth = 2;
        if (originalImage) {
            const maxDim = Math.max(originalImage.width, originalImage.height);
            dynamicStrokeWidth = Math.max(2, Math.floor(maxDim * 0.003));
        }
        
        for (const group of groups) {
            const allObjects = group.getObjects();
            const contentMembers = allObjects.filter(o => !o.isVisualFrame && !o.isStar);
            const starMembers = allObjects.filter(o => o.isStar && o.visible && o.starReason && o.starReason.startsWith("内部空隙警告"));
            
            if (contentMembers.length < 2 || starMembers.length === 0) continue;

            const groupMatrix = group.calcTransformMatrix();
            const splitXPositions = starMembers.map(star => {
                const centerPoint = fabric.util.transformPoint({ x: star.left, y: star.top }, groupMatrix);
                return centerPoint.x;
            }).sort((a, b) => a - b);

            const restoredMembers = contentMembers.map(o => {
                const center = fabric.util.transformPoint({ x: o.left, y: o.top }, groupMatrix);
                const absScaleX = o.scaleX * group.scaleX;
                const absScaleY = o.scaleY * group.scaleY;
               
                return new Promise(resolve => {
                    o.clone(cloned => {
                        cloned.set({
                            left: center.x,
                            top: center.y,
                            scaleX: absScaleX,
                            scaleY: absScaleY,
                            originX: 'center',
                            originY: 'center',
                            isBlackish: group.isBlackish,
                            isColorChar: group.isColorChar,
                            isRescuedDark: o.isRescuedDark || false,
                            originalRect: o.originalRect,
                            originalImageHeight: o.originalImageHeight,
                            pixelCount: o.pixelCount,
                            avgColor: o.avgColor,
                            _isOcrCandidate: true,
                            detectedText: "", 
                            rawOcrText: ""
                        });
                        resolve(cloned);
                    });
                });
            });

            const resolvedMembers = await Promise.all(restoredMembers);
            const buckets = Array.from({ length: splitXPositions.length + 1 }, () => []);
            
            resolvedMembers.forEach(member => {
                const mx = member.left;
                let placedIndex = 0;
                for (let i = 0; i < splitXPositions.length; i++) {
                    if (mx < splitXPositions[i]) {
                        placedIndex = i;
                        break;
                    }
                    placedIndex = i + 1;
                }
                buckets[placedIndex].push(member);
            });

            buckets.forEach(bucketMembers => {
                if (bucketMembers.length === 0) return;
                let newTarget;
                if (bucketMembers.length === 1) {
                    newTarget = bucketMembers[0];
                } else {
                    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                    bucketMembers.forEach(m => {
                        const w = m.width * m.scaleX;
                        const h = m.height * m.scaleY;
                        const l = m.left - w/2;
                        const t = m.top - h/2;
                        if (l < minX) minX = l;
                        if (t < minY) minY = t;
                        if (l + w > maxX) maxX = l + w;
                        if (t + h > maxY) maxY = t + h;
                    });
                    
                    const gW = maxX - minX;
                    const gH = maxY - minY;
                    const gCX = minX + gW / 2;
                    const gCY = minY + gH / 2;
                    
                    const strokeColor = group.isBlackish ? 'red' : 'blue';
                    const frame = new fabric.Rect({
                        left: gCX, top: gCY,
                        width: gW, height: gH,
                        originX: 'center', originY: 'center',
                        fill: 'transparent',
                        stroke: strokeColor,
                        strokeWidth: dynamicStrokeWidth,
                        selectable: false, evented: false,
                        isVisualFrame: true
                    });
                    newTarget = new fabric.Group([...bucketMembers, frame], {
                        originX: 'center', originY: 'center',
                        subTargetCheck: true, interactive: true, fill: 'transparent',
                        hasControls: false, hasBorders: false,
                        lockMovementX: true, lockMovementY: true, hoverCursor: 'pointer', objectCaching: false
                    });
                    newTarget._originalMemberCount = bucketMembers.length;
                }
                
                newTarget._isOcrCandidate = true;
                newTarget.isBlackish = group.isBlackish;
                newTarget.isColorChar = group.isColorChar;
                newTarget.isRescuedDarkGroup = bucketMembers.some(m => m.isRescuedDark);
                newTarget.inclusionReason = (group.inclusionReason || "") + " (分割)";
                newGroupsToAdd.push(newTarget);
            });

            fCanvas.remove(group);
        }

        newGroupsToAdd.forEach(newObj => {
            fCanvas.add(newObj);
        });
        return newGroupsToAdd;
    }

    function detectThreePointGradient(ctx, w, h) {
        const imgData = ctx.getImageData(0, 0, w, h);
        const data = imgData.data;
        const getBrightest = (targetX) => {
            let maxB = -1, best = {r:255,g:255,b:255};
            if(targetX<0) targetX=0; if(targetX>=w) targetX=w-1;
            for(let y=0; y<h; y++) {
                const idx = (y*w+targetX)*4;
                const b = data[idx]+data[idx+1]+data[idx+2];
                if(b > maxB) { maxB=b; best={r:data[idx],g:data[idx+1],b:data[idx+2]}; }
            }
            return best;
        };
        const l = getBrightest(0), c = getBrightest(Math.floor(w/2)), r = getBrightest(w-1);
        const diff = (c1,c2) => Math.sqrt((c1.r-c2.r)**2+(c1.g-c2.g)**2+(c1.b-c2.b)**2);
        const maxDiff = Math.max(diff(l,c), diff(c,r), diff(l,r));
        return { hasGradient: maxDiff > 37, reason: `最大色差:${maxDiff.toFixed(1)}` };
    }

    function prepareImageForOCR(sourceCanvas, paddingText = "ABC") {
        const fontSize = Math.max(20, sourceCanvas.height * 0.90);
        const tempCtx = document.createElement('canvas').getContext('2d');
        tempCtx.font = `bold ${fontSize}px sans-serif`;
        const textWidth = tempCtx.measureText(paddingText).width;
        
        const outerPadding = 50; const innerPadding = 10;
        const newWidth = outerPadding*2 + innerPadding*2 + textWidth*2 + sourceCanvas.width;
        const newHeight = Math.max(sourceCanvas.height, fontSize) + outerPadding*2;
        
        const newCanvas = document.createElement('canvas');
        newCanvas.width = newWidth; newCanvas.height = newHeight;
        const ctx = newCanvas.getContext('2d');
        ctx.fillStyle = "#ffffff"; ctx.fillRect(0,0,newWidth,newHeight);
        ctx.font = `bold ${fontSize}px sans-serif`; ctx.fillStyle = "#000000"; ctx.textBaseline = "middle";
        
        const cy = newHeight/2;
        ctx.fillText(paddingText, outerPadding, cy);
        const tx = outerPadding + textWidth + innerPadding;
        const ty = (newHeight - sourceCanvas.height)/2;
        ctx.drawImage(sourceCanvas, tx, ty);
        ctx.fillText(paddingText, tx + sourceCanvas.width + innerPadding, cy);
        return newCanvas;
    }

    // --- メインOCR処理 (リトライ機能付き) ---
    async function startBatchOCR(objects, ocrProcessedCanvas, originalImage, onProgress, fCanvas) {
        const targets = objects.filter(o => o._isOcrCandidate);
        if (targets.length === 0) return [];

        const scheduler = await initTesseractScheduler();
        let completedCount = 0;
        const totalCount = targets.length;
        const results = [];

        // Workerを使った非同期OCR実行関数 (パディング切り替え機能付き)
        const runOCRWithScheduler = async (sourceCvs, padText) => {
            const paddedCanvas = prepareImageForOCR(sourceCvs, padText);
            const finalDataUrl = paddedCanvas.toDataURL('image/jpeg', 1.0);
            
            const result = await scheduler.addJob('recognize', finalDataUrl);
            const rawText = result.data.text.trim();
            
            let cleanText = "";
            if (padText === "ABC") {
                cleanText = rawText.replace(/^[\s\S]*?A\s*B\s*C\s*/, "").replace(/\s*A\s*B\s*C[\s\S]*?$/, "").trim();
            } else if (padText === "ABCあいう") {
                const pattern = "A\\s*B\\s*C\\s*あ\\s*い\\s*う";
                const reStart = new RegExp(`^[\\s\\S]*?${pattern}\\s*`);
                const reEnd = new RegExp(`\\s*${pattern}[\\s\\S]*?$`);
                cleanText = rawText.replace(reStart, "").replace(reEnd, "").trim();
            }
            return { text: cleanText, raw: rawText, image: paddedCanvas };
        };

        const processObjectOCR = async (obj) => {
            // 切り出し座標計算
            let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
            let isTargetInverted = false;
            
            if (obj.originalRect) {
                minX = obj.originalRect.x;
                minY = obj.originalRect.y;
                maxX = obj.originalRect.x + obj.originalRect.w;
                maxY = obj.originalRect.y + obj.originalRect.h;
                isTargetInverted = obj.isInverted;
            } else if (obj.type === 'group') {
                const subObjects = obj.getObjects().filter(o => !o.isVisualFrame);
                subObjects.forEach(o => {
                    if (o.originalRect) {
                        minX = Math.min(minX, o.originalRect.x);
                        minY = Math.min(minY, o.originalRect.y);
                        maxX = Math.max(maxX, o.originalRect.x + o.originalRect.w);
                        maxY = Math.max(maxY, o.originalRect.y + o.originalRect.h);
                    }
                });
                isTargetInverted = subObjects.some(o => o.isInverted);
            }

            if (minX === Infinity) {
                obj.isOcrProcessing = false;
                completedCount++;
                return;
            }

            const margin = 2;
            const cropX = Math.max(0, minX - margin);
            const cropY = Math.max(0, minY - margin);
            const cropW = Math.min(originalImage.width, maxX + margin) - cropX;
            const cropH = Math.min(originalImage.height, maxY + margin) - cropY;

            if (cropW <= 0 || cropH <= 0) {
                 obj.isOcrProcessing = false;
                 completedCount++;
                 return;
            }

            const cropCanvas = document.createElement('canvas');
            cropCanvas.width = cropW; cropCanvas.height = cropH;
            const cropCtx = cropCanvas.getContext('2d');
            
            if (PARAMS.useOriginalForOcr) {
                cropCtx.drawImage(originalImage, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
            } else {
                cropCtx.drawImage(ocrProcessedCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
            }

            const grad = detectThreePointGradient(cropCtx, cropW, cropH);
            if (grad.hasGradient) {
                stripVisualAids(obj, `グラデーション除外`);
                if(fCanvas) fCanvas.requestRenderAll();
                completedCount++;
                return;
            }

            const contentBounds = getContentBounds(obj);
            const currentH = contentBounds.height;
            const currentW = contentBounds.width;

            if (currentH < 5) {
                stripVisualAids(obj, `高さ過小`);
                if(fCanvas) fCanvas.requestRenderAll();
                completedCount++;
                return;
            }

            // OCR実行 (パディング切替リトライロジック復活)
            try {
                // 1回目: "ABC"パディング
                let result = await runOCRWithScheduler(cropCanvas, "ABC");

                // リトライ判定 (怪しい場合は日本語パディングで再試行)
                let shouldRetry = false;
                const txt = result.text || "";
                if (txt) {
                    if (/^\d{3}$/.test(txt)) { shouldRetry = true; } // 3桁数字のみ
                    else if (txt.length >= 10 && /^[%/:;.,\-+=()[\]{}<>!?@#$&]/.test(txt)) { shouldRetry = true; } // 記号開始長文
                }

                if (shouldRetry) {
                    const retryResult = await runOCRWithScheduler(cropCanvas, "ABCあいう");
                    if (retryResult.text) {
                        result = retryResult;
                    }
                }

                let rawText = result.raw;
                let text = result.text;
                
                // --- テキスト整形・補正処理 (Source 1 完全移植) ---
                if (text) {
                    text = text.replace(/(.)[\r\n]+\1/g, '$1');
                    
                    const correctionMap = {
                        '伝絹': '伝統', '修報源': '情報源', '余い': '低い', 'YaAHOO!': 'YAHOO!',
                        '織路': '線路', '聰減': '軽減', '焦練': '熟練', '脹': '服',
                        '痕導': '指導', '郵侯': '郵便', '手維き': '手続き', '鄧': '郵', '国雛': '困難'
                    };
                    for (const [wrong, correct] of Object.entries(correctionMap)) {
                        text = text.split(wrong).join(correct);
                    }

                    text = text.replace(/Al(?![a-zA-Z])/g, 'AI');
                    text = text.replace(/^— (?=.)/, '→ ');
                    text = text.replace(/`([Ss])/g, "'$1");
                    text = text.replace(/`/g, (m,o,s) => (/[a-zA-Z]/.test(s[o-1]||'') && /[a-zA-Z]/.test(s[o+1]||'')) ? m : "「");
                    text = text.replace(/[①-⑳⓪]/g, m => ({'①':'1','②':'2','③':'3','④':'4','⑤':'5','⑥':'6','⑦':'7','⑧':'8','⑨':'9','⑩':'10','⑪':'11','⑫':'12','⑬':'13','⑭':'14','⑮':'15','⑯':'16','⑰':'17','⑱':'18','⑲':'19','⑳':'20','⓪':'0'}[m]));
                    text = text.replace(/,([^ ])/g, ', $1');
                    text = text.replace(/([0-9０-９])\s+\./g, '$1.').replace(/([0-9０-９])\.([^\d\s])/g, '$1. $2');
                    text = text.replace(/:\)/g, ')');
                    text = text.replace(/:([一-龠ぁ-んァ-ヶ])/g, ': $1');
                    text = text.replace(/([一-龠ぁ-んァ-ヶ]):([^\s])/g, '$1: $2');
                    text = text.replace(/([一-龠ぁ-んァ-ヶ]):/g, '$1 :');
                    text = text.replace(/\s+[(\[「（]$/, '');

                    if (text.length >= 10) {
                        text = text.replace(/([^0-9])\1$/, '$1');
                        text = text.replace(/[ヘへ][ヘへ]$/, 'へ');
                        text = text.replace(/%(\d+)/g, '%');
                        text = text.replace(/(\d)\1%$/, '$1%');
                    }

                    // 特定条件の「0」ノイズ除去
                    if (originalImage) {
                        const wRatio = currentW / originalImage.width;
                        const hRatio = currentH / originalImage.height;
                        if (wRatio < 0.02 && hRatio < 0.02 && grad.maxDiff >= 7.0 && text === '0') {
                            text = "";
                            stripVisualAids(obj, `極小ノイズ ("0")`);
                        }
                    }

                    const isTinyHeight = currentH <= (originalImage.height * 0.005);
                    const isSingleCharNotZero = (Array.from(text).length === 1 && text !== '0');
                    const subObjects = (obj.type === 'group') ? obj.getObjects().filter(o => !o.isVisualFrame) : [];
                    const isMultiMemberZero = (text === '0' && subObjects.length >= 2);
                    const isOnlySymbols = /^[\.\,\-\_:;!?'"()\[\]{}<>|/@#$%^&*~`+\\=‘’“”・…]+$/.test(text);
                    const hasAtOrReg = /[@®]/.test(text);
                    const meaningfulChars = text.match(/[a-zA-Z0-9一-龠ぁ-んァ-ヶ]/g) || [];
                    const isInvalidSpecialUsage = hasAtOrReg && meaningfulChars.length < 2;
                    
                    if (!text || isOnlySymbols || isInvalidSpecialUsage || isSingleCharNotZero || isMultiMemberZero || (isTinyHeight && isSingleCharNotZero)) {
                        text = "";
                        stripVisualAids(obj, "ノイズ判定");
                    }

                    if (obj.type === 'group' && text) {
                        const chars = "a-zA-Z0-9_\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FAF";
                        const hasSuspiciousSpace = new RegExp(`[${chars}]\\s[${chars}]`).test(text);
                        const hasDotSpace = /\.\s|\s\./.test(text);
                        if (hasSuspiciousSpace && !hasDotSpace) {
                            const gapStars = obj.getObjects().filter(o => o.isStar && o.starReason && o.starReason.startsWith("内部空隙警告"));
                            gapStars.forEach(star => { star.visible = true; star.opacity = 1; });
                            obj.dirty = true;
                        }
                    }

                    if (text) {
                        const textNoSpace = text.replace(/\s/g, '');
                        if (textNoSpace.length === 2 && /^[0oO]+$/.test(textNoSpace)) {
                            text = ""; stripVisualAids(obj, "丸型図形誤認識");
                        }
                        if (textNoSpace.length === 2 && /^[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ]+$/.test(textNoSpace)) {
                            text = ""; stripVisualAids(obj, "小文字日本語誤認識");
                        }
                    }

                    // ベタ塗り図形判定 (calculatedFillRate を使用)
                    if (obj._isOcrCandidate && obj.calculatedFillRate !== undefined && obj.calculatedFillRate > 99.0) {
                        const solidShapeTextRegex = /^[\.\,\-\_:;!?'"()\[\]{}<>|/@#$%^&*~`+\\=‘’“”・…—─一ー]+$/;
                        if (solidShapeTextRegex.test(text)) {
                             text = ""; 
                             stripVisualAids(obj, `ベタ塗り図形判定 (充填率:${obj.calculatedFillRate.toFixed(1)}% & 記号/線のみ) - OCR除外`);
                        }
                    }

                    if (text && originalImage) {
                        const wRatio = currentW / originalImage.width;
                        const hRatio = currentH / originalImage.height;
                        if (wRatio < 0.02 && hRatio < 0.02) {
                            const textNoSpace = text.replace(/\s/g, '');
                            const totalLen = Array.from(textNoSpace).length;
                            if (totalLen > 0) {
                                const nonSymbolRegex = /[a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
                                let symbolCount = 0;
                                for (const char of Array.from(textNoSpace)) {
                                    if (!nonSymbolRegex.test(char)) symbolCount++;
                                }
                                const symbolRatio = symbolCount / totalLen;
                                if (symbolRatio >= 0.5 && !/\d+%$/.test(textNoSpace)) {
                                    text = ""; stripVisualAids(obj, "極小ノイズ(記号率)");
                                }
                            }
                            if (text) {
                                const isAlnumSymbolSpaceOnly = /^[a-zA-Z0-9\p{P}\p{S}\s]+$/u.test(text);
                                if (isAlnumSymbolSpaceOnly && text.includes(' ')) {
                                    text = ""; stripVisualAids(obj, "極小ノイズ(スペース区切り)");
                                }
                            }
                        }
                        
                        const isTinyPipe = (wRatio <= 0.01 && hRatio <= 0.01 && text.startsWith('|'));
                        const isPipeNumberPattern = /^\|\s*\d/.test(text);
                        if (isTinyPipe || isPipeNumberPattern) {
                            text = ""; stripVisualAids(obj, "パイプノイズ");
                        }
                    }

                    if (text && (obj.isRescuedDarkGroup || obj.isRescuedDark || obj.isColorChar || obj.isBlackish)) {
                        const textNoSpace = text.replace(/\s/g, '');
                        const totalLen = Array.from(textNoSpace).length;
                        if (totalLen > 0) {
                            const nonSymbolRegex = /[a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/;
                            let symbolCount = 0;
                            for (const char of Array.from(textNoSpace)) {
                                if (!nonSymbolRegex.test(char)) symbolCount++;
                            }
                            const symbolRatio = symbolCount / totalLen;
                            let threshold = 1.0;
                            if (obj.isRescuedDarkGroup || obj.isRescuedDark) threshold = 0.40;
                            else if (obj.isColorChar) threshold = 0.50;
                            else if (obj.isBlackish) threshold = 0.50;

                            if (threshold < 1.0 && symbolRatio >= threshold && !/\d+%$/.test(textNoSpace)) {
                                text = ""; stripVisualAids(obj, "記号過多誤認識");
                            }
                        }
                    }

                    if (text && (obj.isColorChar || obj.isRescuedDarkGroup || obj.isRescuedDark)) {
                        const meaningfulText = text.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, '');
                        if (meaningfulText.length === 2 && meaningfulText !== 'AI') {
                             text = ""; stripVisualAids(obj, "2文字誤認識");
                        }
                    }

                    const notebookLmRegex = /Notebook\s*LM[\s\.\-_]*$/i;
                    if (originalImage && text && notebookLmRegex.test(text.trim())) {
                        const centerX = obj.left;
                        const centerY = obj.top;
                        if (centerX > fCanvas.width * 0.5 && centerY > fCanvas.height * 0.5) {
                            text = ""; stripVisualAids(obj, "NotebookLMロゴ");
                        }
                    }
                }

                if (text) {
                    obj.detectedText = text;
                    results.push({
                        id: Math.random().toString(36).substr(2, 9),
                        text: text,
                        raw_text: rawText,
                        box: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
                        color: obj.avgColor || {r:0,g:0,b:0},
                        attributes: {
                            is_vertical: (cropH > cropW * 1.5),
                            is_blackish: !!obj.isBlackish,
                            is_color: !!obj.isColorChar
                        }
                    });
                }
            } catch(e) { console.error(e); }

            completedCount++;
            if (onProgress) onProgress(`OCR解析中: ${completedCount} / ${totalCount}`);
            if(fCanvas) fCanvas.requestRenderAll();
        };

        await Promise.all(targets.map(obj => processObjectOCR(obj)));

        // --- 再帰処理: 星マーク（内部空隙）によるグループ分割 ---
        const groupsToSplit = targets.filter(obj => {
            if (obj.type !== 'group' || !obj._isOcrCandidate) return false;
            const activeGapStars = obj.getObjects().filter(o => 
                o.isStar && o.visible && o.starReason && o.starReason.startsWith("内部空隙警告")
            );
            return activeGapStars.length > 0;
        });

        if (groupsToSplit.length > 0) {
            console.log(`[TextEngine] 分割再解析: ${groupsToSplit.length}個のグループを分割します`);
            if(onProgress) onProgress("空隙によりグループを分割して再解析中...");
            
            const newTargets = await splitGroupsAtStarsAndRefactor(groupsToSplit, fCanvas, originalImage);
            if (newTargets.length > 0) {
                const recursiveResults = await startBatchOCR(newTargets, ocrProcessedCanvas, originalImage, onProgress, fCanvas);
                results.push(...recursiveResults);
            }
        }
        
        // --- 重なり検出と結合再解析 ---
        const allObjectsOnCanvas = fCanvas.getObjects();
        const overlappedGroups = await checkAndMergeOverlaps(allObjectsOnCanvas, fCanvas);
        
        if (overlappedGroups.length > 0) {
            console.log(`重なり検出: ${overlappedGroups.length}個の結合グループを再解析します`);
            if(onProgress) onProgress("重なった領域を結合して再解析中...");
            
            const overlappedResults = await startBatchOCR(overlappedGroups, ocrProcessedCanvas, originalImage, onProgress, fCanvas);
            results.push(...overlappedResults);
        }

        return results;
    }
    
    // --- 重なっているOCR候補を結合して再OCRをかける関数 ---
    async function checkAndMergeOverlaps(objects, fCanvas) {
        const candidates = objects.filter(o => o._isOcrCandidate && o.visible);
        if (candidates.length < 2) return [];

        const mergedGroups = [];
        const processed = new Set();

        const getBounds = (o) => {
            const w = o.width * o.scaleX;
            const h = o.height * o.scaleY;
            return { 
                l: o.left - w/2, 
                t: o.top - h/2, 
                r: o.left + w/2, 
                b: o.top + h/2,
                w: w, h: h,
                obj: o 
            };
        };

        let boxes = candidates.map(getBounds);

        for (let i = 0; i < boxes.length; i++) {
            if (processed.has(boxes[i].obj)) continue;

            let currentRect = { ...boxes[i] };
            let members = [boxes[i].obj];
            processed.add(boxes[i].obj);

            let changed = true;
            while(changed) {
                changed = false;
                for (let j = i + 1; j < boxes.length; j++) {
                    if (processed.has(boxes[j].obj)) continue;

                    const b2 = boxes[j];
                    const margin = 5; 
                    
                    const isOverlapping = !(
                        b2.l > currentRect.r - margin || 
                        b2.r < currentRect.l + margin || 
                        b2.t > currentRect.b - margin || 
                        b2.b < currentRect.t + margin
                    );

                    if (isOverlapping) {
                        const type1 = !!boxes[i].obj.isBlackish;
                        const type2 = !!b2.obj.isBlackish;
                        
                        if (type1 !== type2) continue;

                        currentRect.l = Math.min(currentRect.l, b2.l);
                        currentRect.t = Math.min(currentRect.t, b2.t);
                        currentRect.r = Math.max(currentRect.r, b2.r);
                        currentRect.b = Math.max(currentRect.b, b2.b);
                        
                        members.push(b2.obj);
                        processed.add(b2.obj);
                        changed = true; 
                    }
                }
            }

            if (members.length > 1) {
                const newW = currentRect.r - currentRect.l;
                const newH = currentRect.b - currentRect.t;
                const newCX = currentRect.l + newW / 2;
                const newCY = currentRect.t + newH / 2;

                members.forEach(m => {
                    fCanvas.remove(m);
                });

                members.sort((a,b) => (a.top - b.top) || (a.left - b.left));
                const baseObj = members[0];

                const strokeColor = baseObj.isBlackish ? 'red' : (baseObj.isColorChar ? 'blue' : '#059669');
                
                let strokeW = 2;
                try {} catch(e) {}

                const frame = new fabric.Rect({
                    left: newCX, top: newCY,
                    width: newW, height: newH,
                    originX: 'center', originY: 'center',
                    fill: 'transparent',
                    stroke: strokeColor,
                    strokeWidth: strokeW,
                    selectable: false, evented: false,
                    isVisualFrame: true
                });

                const groupContent = await Promise.all(members.map(m => {
                    return new Promise(resolve => {
                        m.clone(cloned => {
                            cloned.visible = false; 
                            resolve(cloned);
                        });
                    });
                }));

                const newGroup = new fabric.Group([...groupContent, frame], {
                    left: newCX, top: newCY,
                    originX: 'center', originY: 'center',
                    subTargetCheck: true,
                    interactive: true,
                    fill: 'transparent',
                    hasControls: false,
                    hasBorders: false,
                    lockMovementX: true,
                    lockMovementY: true,
                    hoverCursor: 'pointer',
                    objectCaching: false
                });

                newGroup._isOcrCandidate = true;
                newGroup.isBlackish = baseObj.isBlackish;
                newGroup.isColorChar = baseObj.isColorChar;
                newGroup.isRescuedDarkGroup = members.some(m => m.isRescuedDark || m.isRescuedDarkGroup);
                newGroup.inclusionReason = baseObj.inclusionReason + "<br><span class='text-[9px] text-pink-500'>※重なり結合により再生成</span>";
                newGroup._originalMemberCount = members.length;
                
                let origMinX = Infinity, origMinY = Infinity, origMaxX = -Infinity, origMaxY = -Infinity;
                
                members.forEach(m => {
                    if (m.originalRect) {
                         origMinX = Math.min(origMinX, m.originalRect.x);
                         origMinY = Math.min(origMinY, m.originalRect.y);
                         origMaxX = Math.max(origMaxX, m.originalRect.x + m.originalRect.w);
                         origMaxY = Math.max(origMaxY, m.originalRect.y + m.originalRect.h);
                    } 
                    else if(m.type === 'group') {
                        m.getObjects().forEach(child => { 
                            if(child.originalRect) {
                                origMinX = Math.min(origMinX, child.originalRect.x);
                                origMinY = Math.min(origMinY, child.originalRect.y);
                                origMaxX = Math.max(origMaxX, child.originalRect.x + child.originalRect.w);
                                origMaxY = Math.max(origMaxY, child.originalRect.y + child.originalRect.h);
                            }
                        });
                    }
                });
                
                const finalW = origMaxX - origMinX;
                const finalH = origMaxY - origMinY;

                if (origMinX !== Infinity && finalW > 0 && finalH > 0) {
                    newGroup.originalRect = { 
                        x: origMinX, 
                        y: origMinY, 
                        w: finalW, 
                        h: finalH 
                    };
                } else {
                     newGroup.originalRect = { x:0, y:0, w:10, h:10 };
                }

                mergedGroups.push(newGroup);
                fCanvas.add(newGroup); 
            }
        }
        return mergedGroups; 
    }

    // --- メイン処理 ---
    async function process(imageElement, onStatusUpdate) {
        return new Promise(async (resolve, reject) => {
            try {
                const w = imageElement.width;
                const h = imageElement.height;
                const totalImagePixels = w * h;
                
                // ★追加: 背景色を自動計算 (これが欠落していたため、白背景以外が全滅していた)
                const domColor = calculateDominantColor(imageElement);
                
                // 1. OCR用前処理画像の作成
                const ocrCanvas = document.createElement('canvas');
                ocrCanvas.width = w; ocrCanvas.height = h;
                const ocrCtx = ocrCanvas.getContext('2d');
                ocrCtx.drawImage(imageElement, 0, 0);
                 
                // 背景除去・文字強調
                if (!PARAMS.useOriginalForOcr) {
                    const imgData = ocrCtx.getImageData(0,0,w,h);
                    const data = imgData.data;
                    const bgR = domColor.r, bgG = domColor.g, bgB = domColor.b;
                    
                    for(let i=0; i<data.length; i+=4) {
                        const r = data[i], g = data[i+1], b = data[i+2];
                        const distance = Math.sqrt((r - bgR)**2 + (g - bgG)**2 + (b - bgB)**2);
                        
                        if (distance < PARAMS.threshold) {
                            data[i]=255; data[i+1]=255; data[i+2]=255; data[i+3]=255;
                        } else {
                             if (PARAMS.forceBlackText) {
                                let isBlackened = false;
                                const brightness = (r + g + b) / 3;
                                if (brightness < 100) {
                                    const isReddish = (r > g + 15) && (r > b + 15);
                                    const isGreenish = (g > r + 15) && (g > b + 15);
                                    if (!isReddish && !isGreenish) {
                                        data[i] = 0; data[i+1] = 0; data[i+2] = 0;
                                        isBlackened = true;
                                    }
                                }
                                if (!isBlackened) {
                                    data[i] = Math.floor(data[i] * 0.6);
                                    data[i+1] = Math.floor(data[i+1] * 0.6);
                                    data[i+2] = Math.floor(data[i+2] * 0.6);
                                }
                            }
                        }
                    }
                    ocrCtx.putImageData(imgData, 0, 0);
                    if (PARAMS.sharpenImage) applySharpenFilter(ocrCtx, w, h);
                }

                // 2. 仮想Fabric Canvas作成
                const fCanvas = new fabric.Canvas(null, { width: w, height: h });

                if(onStatusUpdate) onStatusUpdate("画像解析中...");
                
                // 3. 領域抽出
                const tempC = document.createElement('canvas');
                tempC.width = w; tempC.height = h;
                const ctx = tempC.getContext('2d');
                ctx.drawImage(imageElement, 0, 0);
                if (PARAMS.sharpenImage) applySharpenFilter(ctx, w, h);
                const imageData = ctx.getImageData(0,0,w,h);
                const d = imageData.data;
                
                let bgPixelCount = 0;
                const bgR = domColor.r, bgG = domColor.g, bgB = domColor.b;

                for(let i=0; i<d.length; i+=4) {
                    const r = d[i], g = d[i+1], b = d[i+2];
                    const distance = Math.sqrt((r - bgR)**2 + (g - bgG)**2 + (b - bgB)**2);
                    if (distance < PARAMS.threshold) {
                        d[i+3] = 0;
                        bgPixelCount++;
                    } else {
                        if (PARAMS.forceBlackText) {
                            let isBlackened = false;
                            const brightness = (r + g + b) / 3;
                            if (brightness < 100) {
                                const isReddish = (r > g + 15) && (r > b + 15);
                                const isGreenish = (g > r + 15) && (g > b + 15);
                                if (!isReddish && !isGreenish) {
                                    d[i] = 0; d[i+1] = 0; d[i+2] = 0;
                                    isBlackened = true;
                                }
                            }
                            if (!isBlackened) {
                                d[i] = Math.floor(d[i] * 0.6);
                                d[i+1] = Math.floor(d[i+1] * 0.6);
                                d[i+2] = Math.floor(d[i+2] * 0.6);
                            }
                        }
                    }
                }

                const blobs = findConnectedComponents(w, h, d);
                
                if(onStatusUpdate) onStatusUpdate(`オブジェクト生成中 (${blobs.length}個)...`);
                
                const fabricPromises = blobs.map(blob => 
                    createFabricObjectFromBlob(blob, w, h, totalImagePixels)
                );
                
                const fabricObjects = await Promise.all(fabricPromises);
                
                const validObjects = fabricObjects.filter(o => {
                    if(!o) return false;
                    const area = o.originalRect.w * o.originalRect.h;
                    const density = area > 0 ? (o.pixelCount / area) * 100 : 0;
                    return density > 5.0; 
                });
                
                if(onStatusUpdate) onStatusUpdate("テキストグルーピング中...");
                groupAndAddObjects(validObjects, fCanvas);

                const allObjs = fCanvas.getObjects();
                
                if(onStatusUpdate) onStatusUpdate("オブジェクトの充填率を確認しています...");
                await checkAndFilterByFillRate(allObjs, onStatusUpdate);

                // 4. OCR実行
                if(onStatusUpdate) onStatusUpdate("OCR実行中...");
                const jsonResults = await startBatchOCR(allObjs, ocrCanvas, imageElement, onStatusUpdate, fCanvas);

                fCanvas.dispose();
                resolve(jsonResults);

            } catch (e) {
                reject(e);
            }
        });
    }

    return { process: process };
})();


















/**
 * AutoImageSplitter (完全版: OCR連携・背景インペイント・軽量化対応)
 */
const AutoImageSplitter = (function() {
  const DEFAULT_PARAMS = {
    threshold: 30,
    minSegmentPixels: 100, 
    transparencyThreshold: 15 
  };

  // 1. ピクセルの輝度を取得
  function getPixelIntensity(data, i) {
    return data[i + 3] === 0 ? 0 : 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
  }

  // 2. エッジ検出 (Sobelフィルタ的な処理)
  function detectEdges(data, width, height, threshold) {
    const edgeMap = new Uint8Array(width * height);
    const kernelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
    const kernelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];

    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        let pixelX = 0;
        let pixelY = 0;
        for (let ky = -1; ky <= 1; ky++) {
          const rowOffset = (y + ky) * width;
          for (let kx = -1; kx <= 1; kx++) {
            const i = (rowOffset + (x + kx)) * 4;
            const intensity = getPixelIntensity(data, i);
            pixelX += intensity * kernelX[ky + 1][kx + 1];
            pixelY += intensity * kernelY[ky + 1][kx + 1];
          }
        }
        const magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY);
        if (magnitude > threshold) {
          edgeMap[y * width + x] = 255;
        }
      }
    }
    return edgeMap;
  }

  // 3. 背景の穴埋め色を計算 (インペイント用)
  function calculateInpaintedColors(pixels, width, height, segmentPixels) {
    const segmentSet = new Set(segmentPixels);
    const colorMap = new Map();

    const addColor = (idx, r, g, b) => {
        if (!colorMap.has(idx)) {
            colorMap.set(idx, { r: 0, g: 0, b: 0, count: 0 });
        }
        const entry = colorMap.get(idx);
        entry.r += r; entry.g += g; entry.b += b; entry.count++;
    };

    let minX = width, maxX = 0, minY = height, maxY = 0;
    for (let idx of segmentPixels) {
        const x = idx % width;
        const y = Math.floor(idx / width);
        if (x < minX) minX = x; if (x > maxX) maxX = x;
        if (y < minY) minY = y; if (y > maxY) maxY = y;
    }

    // 横方向の補間
    for (let y = minY; y <= maxY; y++) {
        let x = minX;
        while (x <= maxX) {
            const idx = y * width + x;
            if (segmentSet.has(idx)) {
                let startX = x;
                while (x <= maxX && segmentSet.has(y * width + x)) x++;
                let endX = x - 1;

                let leftColor = null;
                for (let lx = startX - 1; lx >= 0; lx--) {
                    const lIdx = y * width + lx;
                    if (!segmentSet.has(lIdx)) {
                        leftColor = [pixels[lIdx * 4], pixels[lIdx * 4 + 1], pixels[lIdx * 4 + 2]];
                        break;
                    }
                }
                let rightColor = null;
                for (let rx = endX + 1; rx < width; rx++) {
                    const rIdx = y * width + rx;
                    if (!segmentSet.has(rIdx)) {
                        rightColor = [pixels[rIdx * 4], pixels[rIdx * 4 + 1], pixels[rIdx * 4 + 2]];
                        break;
                    }
                }

                const len = endX - startX + 1;
                for (let k = 0; k < len; k++) {
                    const curIdx = y * width + (startX + k);
                    let r = 0, g = 0, b = 0, valid = false;
                    if (leftColor && rightColor) {
                        const weightR = (k + 1) / (len + 1);
                        const weightL = 1 - weightR;
                        r = leftColor[0] * weightL + rightColor[0] * weightR;
                        g = leftColor[1] * weightL + rightColor[1] * weightR;
                        b = leftColor[2] * weightL + rightColor[2] * weightR;
                        valid = true;
                    } else if (leftColor) {
                        r = leftColor[0]; g = leftColor[1]; b = leftColor[2]; valid = true;
                    } else if (rightColor) {
                        r = rightColor[0]; g = rightColor[1]; b = rightColor[2]; valid = true;
                    }
                    if (valid) addColor(curIdx, r, g, b);
                }
            } else {
                x++;
            }
        }
    }

    // 縦方向の補間
    for (let x = minX; x <= maxX; x++) {
        let y = minY;
        while (y <= maxY) {
            const idx = y * width + x;
            if (segmentSet.has(idx)) {
                let startY = y;
                while (y <= maxY && segmentSet.has(y * width + x)) y++;
                let endY = y - 1;

                let topColor = null;
                for (let ty = startY - 1; ty >= 0; ty--) {
                    const tIdx = ty * width + x;
                    if (!segmentSet.has(tIdx)) {
                        topColor = [pixels[tIdx * 4], pixels[tIdx * 4 + 1], pixels[tIdx * 4 + 2]];
                        break;
                    }
                }
                let bottomColor = null;
                for (let by = endY + 1; by < height; by++) {
                    const bIdx = by * width + x;
                    if (!segmentSet.has(bIdx)) {
                        bottomColor = [pixels[bIdx * 4], pixels[bIdx * 4 + 1], pixels[bIdx * 4 + 2]];
                        break;
                    }
                }

                const len = endY - startY + 1;
                for (let k = 0; k < len; k++) {
                    const curIdx = (startY + k) * width + x;
                    let r = 0, g = 0, b = 0, valid = false;
                    if (topColor && bottomColor) {
                        const weightB = (k + 1) / (len + 1);
                        const weightT = 1 - weightB;
                        r = topColor[0] * weightT + bottomColor[0] * weightB;
                        g = topColor[1] * weightT + bottomColor[1] * weightB;
                        b = topColor[2] * weightT + bottomColor[2] * weightB;
                        valid = true;
                    } else if (topColor) {
                        r = topColor[0]; g = topColor[1]; b = topColor[2]; valid = true;
                    } else if (bottomColor) {
                        r = bottomColor[0]; g = bottomColor[1]; b = bottomColor[2]; valid = true;
                    }
                    if (valid) addColor(curIdx, r, g, b);
                }
            } else {
                y++;
            }
        }
    }

    const result = new Map();
    colorMap.forEach((val, key) => {
        if (val.count > 0) {
            result.set(key, { r: val.r / val.count, g: val.g / val.count, b: val.b / val.count });
        } else {
            result.set(key, {r: 255, g: 255, b: 255});
        }
    });
    return result;
  }

  // 4. ピクセル集合からレイヤー画像を生成
  function createLayerFromSegment(segmentPixels, width, height, originalPixels, backgroundMap, transparencyThreshold) {
    let minX = width, maxX = 0, minY = height, maxY = 0;
    for (let i = 0; i < segmentPixels.length; i++) {
      const idx = segmentPixels[i];
      const x = idx % width;
      const y = Math.floor(idx / width);
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }
    const segWidth = maxX - minX + 1;
    const segHeight = maxY - minY + 1;
    
    const canvas = document.createElement('canvas');
    canvas.width = segWidth;
    canvas.height = segHeight;
    const ctx = canvas.getContext('2d');
    const imgData = ctx.createImageData(segWidth, segHeight);
    const data = imgData.data;

    for (let i = 0; i < segmentPixels.length; i++) {
      const idx = segmentPixels[i];
      const x = idx % width;
      const y = Math.floor(idx / width);
      const srcI = idx * 4;
      const destI = ((y - minY) * segWidth + (x - minX)) * 4;
      
      const r = originalPixels[srcI];
      const g = originalPixels[srcI + 1];
      const b = originalPixels[srcI + 2];
      const a = originalPixels[srcI + 3];

      const bg = backgroundMap.get(idx) || {r: 0, g: 0, b: 0};
      const dist = Math.sqrt(Math.pow(r - bg.r, 2) + Math.pow(g - bg.g, 2) + Math.pow(b - bg.b, 2));

      if (dist < transparencyThreshold) {
        data[destI + 3] = 0; 
      } else {
        data[destI] = r; data[destI + 1] = g; data[destI + 2] = b; data[destI + 3] = a;
      }
    }
    ctx.putImageData(imgData, 0, 0);
    
    return {
      x: minX,
      y: minY,
      width: segWidth,
      height: segHeight,
      dataBase64: canvas.toDataURL('image/png')
    };
  }

  // 5. 元画像（背景）にインペイント処理を適用
  function applyInpaintingToPixels(pixels, backgroundMap) {
      backgroundMap.forEach((color, idx) => {
          const baseI = idx * 4;
          pixels[baseI] = color.r;
          pixels[baseI + 1] = color.g;
          pixels[baseI + 2] = color.b;
          pixels[baseI + 3] = 255;
      });
  }

  // --- メイン処理 ---
  async function process(imageElementOrCanvas, onStatusUpdate, options = {}, ocrData = null) {
    return new Promise((resolve, reject) => {
      const params = { ...DEFAULT_PARAMS, ...options };
      
      try {
        if(onStatusUpdate) onStatusUpdate("画像解析中...");
        setTimeout(() => {
            const width = imageElementOrCanvas.width;
            const height = imageElementOrCanvas.height;
            const canvas = document.createElement('canvas');
            canvas.width = width;
            canvas.height = height;
            const ctx = canvas.getContext('2d', { willReadFrequently: true });
            ctx.drawImage(imageElementOrCanvas, 0, 0, width, height);

            const mainImgData = ctx.getImageData(0, 0, width, height);
            const pixels = mainImgData.data;

            const layers = [];
            // 分割済みのピクセルを記録するマップ (0:未訪問, 1:訪問済み)
            const visitedObject = new Uint8Array(width * height);



            // ▼▼▼ [1] OCRデータに基づく優先切り出し処理 ▼▼▼
if (ocrData && Array.isArray(ocrData)) {
    if(onStatusUpdate) onStatusUpdate("テキスト領域を優先抽出中...");
    
    ocrData.forEach(item => {
        if (!item.box) return;
        
        const box = item.box;
        
        // ★★★ 修正: 3px 外側へ拡張するロジック ★★★
        const PADDING = 3; 

        // 1. 拡張した座標 (左上と右下) を計算
        let startX = Math.floor(box.x) - PADDING;
        let startY = Math.floor(box.y) - PADDING;
        let endX = Math.floor(box.x + box.width) + PADDING;
        let endY = Math.floor(box.y + box.height) + PADDING;

        // 2. 画像の範囲内に収める (0 〜 width/height の間に制限)
        startX = Math.max(0, startX);
        startY = Math.max(0, startY);
        endX = Math.min(width, endX);
        endY = Math.min(height, endY);

        // 3. 最終的な開始位置とサイズを決定
        const bx = startX;
        const by = startY;
        const bw = endX - startX;
        const bh = endY - startY;
        // ★★★ 修正ここまで ★★★

        // 範囲チェック (bw, bh が正の値であることを確認)
        if (bw <= 0 || bh <= 0) return;



                    // 矩形内の有効なピクセルを収集
                    const segmentPixels = [];
                    for(let y=0; y<bh; y++) {
                        for(let x=0; x<bw; x++) {
                            const globalX = bx + x;
                            const globalY = by + y;
                            if(globalX < width && globalY < height) {
                                const idx = globalY * width + globalX;
                                // すでに訪問済みでなく、透明でないピクセル
                                if (visitedObject[idx] === 0 && pixels[idx * 4 + 3] !== 0) {
                                    segmentPixels.push(idx);
                                    visitedObject[idx] = 1; // 訪問済みにする
                                }
                            }
                        }
                    }
                    
                    // レイヤー生成とインペイント
                    if (segmentPixels.length > 0) {
                        const bgMap = calculateInpaintedColors(pixels, width, height, segmentPixels);
                        const layer = createLayerFromSegment(segmentPixels, width, height, pixels, bgMap, params.transparencyThreshold);
                        
                        // OCR情報を付与
                        layer.ocrData = item; 
                        
                        layers.push(layer);
                        applyInpaintingToPixels(pixels, bgMap); // 背景を塗りつぶす
                    }
                });
            }
            // ▲▲▲ [1] ここまで ▲▲▲

            // ▼▼▼ [2] 残りの領域に対する自動分割処理 (連結成分分析) ▼▼▼
            if(onStatusUpdate) onStatusUpdate("その他の要素を抽出中...");
            
            const edgeMap = detectEdges(pixels, width, height, params.threshold);
            const blockingMap = new Uint8Array(width * height);
            
            // エッジマップからブロッキングマップ作成
            for (let y = 1; y < height - 1; y++) {
              for (let x = 1; x < width - 1; x++) {
                const idx = y * width + x;
                if (edgeMap[idx] === 255) {
                   blockingMap[idx] = 255;
                   // 周辺をブロック
                   blockingMap[idx-1]=255; blockingMap[idx+1]=255;
                   blockingMap[idx-width]=255; blockingMap[idx+width]=255;
                }
              }
            }

            const labels = new Uint8Array(width * height); 
            const queue = [];
            // 背景特定シード
            const seeds = [[0, 0], [width-1, 0], [0, height-1], [width-1, height-1]];
            seeds.forEach(([sx, sy]) => {
              const idx = sy * width + sx;
              if (blockingMap[idx] !== 255 && visitedObject[idx] === 0) { // visitedチェック追加
                  queue.push(idx); labels[idx] = 1; 
              }
            });

            // 背景塗りつぶし
            let head = 0;
            while (head < queue.length) {
              const idx = queue[head++];
              const cx = idx % width; const cy = Math.floor(idx / width);
              const checkAndPush = (nIdx) => {
                if (labels[nIdx] === 0 && blockingMap[nIdx] !== 255 && visitedObject[nIdx] === 0) {
                  labels[nIdx] = 1; queue.push(nIdx);
                }
              };
              if (cx > 0) checkAndPush(idx - 1);
              if (cx < width - 1) checkAndPush(idx + 1);
              if (cy > 0) checkAndPush(idx - width);
              if (cy < height - 1) checkAndPush(idx + width);
            }

            // オブジェクト抽出
            for (let y = 0; y < height; y+=2) { 
              for (let x = 0; x < width; x+=2) {
                const idx = y * width + x;
                // まだ訪問されておらず、背景でもなく、透明でもない場合
                if (labels[idx] !== 1 && pixels[idx*4+3] !== 0 && visitedObject[idx] === 0) {
                  const segmentPixels = [];
                  const objQueue = [idx];
                  visitedObject[idx] = 1; 
                  
                  let qHead = 0;
                  while (qHead < objQueue.length) {
                    const currIdx = objQueue[qHead++];
                    segmentPixels.push(currIdx);
                    const cx = currIdx % width; const cy = Math.floor(currIdx / width);
                    const checkObj = (nIdx) => {
                      if (visitedObject[nIdx] === 0 && labels[nIdx] !== 1 && pixels[nIdx*4+3] !== 0) {
                        visitedObject[nIdx] = 1; objQueue.push(nIdx);
                      }
                    };
                    if (cx > 0) checkObj(currIdx - 1);
                    if (cx < width - 1) checkObj(currIdx + 1);
                    if (cy > 0) checkObj(currIdx - width);
                    if (cy < height - 1) checkObj(currIdx + width);
                  }
                  
                  if (segmentPixels.length > params.minSegmentPixels) { 
                    const bgMap = calculateInpaintedColors(pixels, width, height, segmentPixels);
                    const layer = createLayerFromSegment(segmentPixels, width, height, pixels, bgMap, params.transparencyThreshold);
                    layers.push(layer);
                    applyInpaintingToPixels(pixels, bgMap); // 背景を塗りつぶす
                  }
                }
              }
            }
            // ▲▲▲ [2] ここまで ▲▲▲

            // 処理後の画像データ（背景）をキャンバスに戻す
            ctx.putImageData(mainImgData, 0, 0);
            
            // ★重要: 背景画像はJPEG形式 (品質80%) にして軽量化
            const bgBase64 = canvas.toDataURL('image/jpeg', 0.8);

            resolve({
              background: bgBase64,
              layers: layers,
              originalWidth: width,
              originalHeight: height
            });
        }, 50); 
      } catch (e) {
        reject(e);
      }
    });
  }

  return { process: process };
})();


/* ============================================================================
 * MajinSplitAdapter — 画像分解/テキスト抽出のエンジン切替アダプタ
 * ----------------------------------------------------------------------------
 * 目的: 既存(legacy=TextExtractionEngine/AutoImageSplitter)を温存しつつ、
 *       フラグ useNotebookLmSplit ON かつ window.NLM ロード済み時のみ
 *       NotebookLM の最新エンジン(window.NLM.*)へ切替える。
 * 返り値は常にまじん現行スキーマに揃えるため下流(renderResult/reflectToSlide
 * /preLoad/最終JSON imageMap差替)は一切改修不要。
 * NotebookLMエンジンを差し替える際に唯一調整が要るのはこの notebooklm 分岐のみ。
 * ========================================================================== */
const MajinSplitAdapter = (function () {
  function resolveMode(override) {
    if (override === 'legacy' || override === 'notebooklm') return override;
    let on = false;
    // チェックボックスを直接読む(getCurrentSettings の多数のDOM走査を避け、例外も起こしにくい)
    try { const el = document.getElementById('useNotebookLmSplit'); on = !!(el && el.checked); }
    catch (e) { console.warn('[MajinSplitAdapter] フラグ読取失敗、legacyにフォールバック:', e); }
    if (on && window.NLM && window.NLM.ImageProcessingEngine && window.NLM.PureTextProcessingEngine) return 'notebooklm';
    return 'legacy';
  }
  function toCanvas(imgEl) {
    if (imgEl && (imgEl.tagName === 'CANVAS' || imgEl instanceof HTMLCanvasElement)) return imgEl;
    const c = document.createElement('canvas');
    c.width = imgEl.naturalWidth || imgEl.width;
    c.height = imgEl.naturalHeight || imgEl.height;
    c.getContext('2d').drawImage(imgEl, 0, 0, c.width, c.height);
    return c;
  }
  function pngToJpeg(dataUrl, quality) {
    return new Promise((resolve) => {
      // onload/onerror がCSP・巨大DataURL・デコード失敗で発火しない場合に永久pendingでハングしないようタイムアウトで原PNGへ resolve
      let settled = false;
      const finish = function (v) { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } };
      const timer = setTimeout(function () { finish(dataUrl); }, 8000);
      try {
        if (typeof dataUrl !== 'string' || dataUrl.indexOf('data:image/png') !== 0) { finish(dataUrl); return; }
        const im = new Image();
        im.onload = function () {
          try {
            const c = document.createElement('canvas');
            c.width = im.naturalWidth || im.width; c.height = im.naturalHeight || im.height;
            const ctx = c.getContext('2d');
            ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, c.width, c.height);
            ctx.drawImage(im, 0, 0);
            finish(c.toDataURL('image/jpeg', quality || 0.8));
          } catch (e) { finish(dataUrl); }
        };
        im.onerror = function () { finish(dataUrl); };
        im.src = dataUrl;
      } catch (e) { finish(dataUrl); }
    });
  }
  // OCR/テキスト抽出: HTMLImageElement|Canvas を受け、まじん現行スキーマの配列を返す
  async function runTextExtraction(imgElement, onStatus, engineMode) {
    const mode = resolveMode(engineMode);
    if (mode === 'notebooklm') {
      try {
        const canvas = toCanvas(imgElement);
        // ★[テキスト抽出修正] processImageToJSON(領域分割OCR=元ツール標準)を使う。
        //   旧実装の processRegionToJSON は内部で forceOcr=true を立てるが、その forceOcr 経路は
        //   OCR候補を集約する groupAndAddObjects() をスキップするのに startBatchOCR(all) へ渡す
        //   `all` を埋めないため、候補0件→テキスト抽出ゼロになるエンジン側の不具合がある。
        //   フルスライドから位置付きテキストを抽出するには分割OCRが正しく、後段の画像分解
        //   (text/illustration 分離)にも適合する(実機ハーネスで page04=18件/page06=12件 抽出を確認)。
        const blob = await new Promise(function (res) {
          try { canvas.toBlob(function (b) { res(b); }, 'image/png'); } catch (e2) { res(null); }
        });
        if (!blob) throw new Error('canvas.toBlob が null を返しました');
        const file = new File([blob], 'slide.png', { type: 'image/png' });
        const te = new window.NLM.PureTextProcessingEngine();
        return await te.processImageToJSON(file, function (p, t, d) { if (onStatus) onStatus(t || d || ''); });
      } catch (e) {
        console.warn('[MajinSplitAdapter] NLM テキスト抽出に失敗、legacy にフォールバック:', e);
      }
    }
    return await TextExtractionEngine.process(imgElement, onStatus);
  }
  // 画像分解: 画像/Canvas + OCR配列 + options を受け、まじん現行スキーマ {background,layers,originalWidth,originalHeight} を返す
  async function runImageSplit(imgOrCanvas, ocrData, options, onStatus, engineMode) {
    const mode = resolveMode(engineMode);
    if (mode === 'notebooklm') {
      try {
        const ie = new window.NLM.ImageProcessingEngine();
        const r = await ie.processImageToLayers(imgOrCanvas, ocrData || null, options || {}, function (s) { if (onStatus) onStatus(s); });
        // まじんの layersToElements は ocrData 有無で text/illustration を判定する。
        // 現行NLMエンジンは is_illustration を「ocrData を持たないレイヤー」にのみ付与するため
        // 下記は実質no-op(=現状の下流挙動は正しい)。将来エンジンが is_illustration かつ ocrData を
        // 同時に持つ形に変わった場合の保険として ocrData を剥がしイラスト扱いに正規化しておく。
        const layers = (r.layers || []).map(function (l) {
          if (l && l.is_illustration && l.ocrData) {
            const copy = {}; for (const k in l) { if (k !== 'ocrData') copy[k] = l[k]; } return copy;
          }
          return l;
        });
        // 背景は NotebookLM=PNG。まじん現行(JPEG0.8)に揃えてサイズ/5MB境界を整合
        let bg = r.background;
        bg = await pngToJpeg(bg, 0.8);
        return { background: bg, layers: layers, originalWidth: r.originalWidth, originalHeight: r.originalHeight };
      } catch (e) {
        console.warn('[MajinSplitAdapter] NLM 画像分解に失敗、legacy にフォールバック:', e);
      }
    }
    return await AutoImageSplitter.process(imgOrCanvas, onStatus, options, ocrData);
  }
  return { runTextExtraction: runTextExtraction, runImageSplit: runImageSplit, resolveMode: resolveMode };
})();


/**
 * ▼▼▼ [修正版] 画像分解ツール専用 独立SVGエディタ ▼▼▼
 * 修正点:
 * 1. 選択時のDOM移動（最前面化）を廃止し、ダブルクリック動作を安定化
 * 2. テキスト編集エリアのスタイルをJSで直接指定（CSS依存を排除）
 */
const InlineSvgEditor = (function() {
    let currentSvg = null;
    let selectedEl = null;
    let resizeGroup = null;

    // 状態管理
    let isDragging = false;
    let isResizing = false;
    let activeHandle = null;
    let dragStartPos = { x: 0, y: 0 };
    
    // 変形計算用キャッシュ
    let initTransform = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
    let initBBox = null;

    // テキスト編集用
    let editingTextEl = null;
    let foreignObject = null;
    let isEditing = false;

    // ★[マーキー] 範囲選択→テキスト化 用
    let marqueeMode = false;
    let isMarqueeDragging = false;
    let marqueeStart = { x: 0, y: 0 };
    let marqueeRectEl = null;
    let onMarqueeConfirm = null;

    // --- 初期化 ---
    function init(svgElement) {
        if (!svgElement) return;
        currentSvg = svgElement;
        // ★[マーキー] ページ切替後もモード継続中ならカーソルを再適用
        if (marqueeMode) currentSvg.style.cursor = 'crosshair';

        // ハンドルグループ作成
        createHandleGroup();

        // イベントリスナー登録
        currentSvg.addEventListener('mousedown', onMouseDown);
        currentSvg.addEventListener('dblclick', onDoubleClick);
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);

        // 背景クリックで選択解除
        currentSvg.addEventListener('click', (e) => {
            if (e.target === currentSvg && !isDragging && !isResizing) {
                clearSelection();
                if (isEditing) finishTextEditing();
            }
        });
    }

    function cleanup() {
        if (!currentSvg) return;
        currentSvg.removeEventListener('mousedown', onMouseDown);
        currentSvg.removeEventListener('dblclick', onDoubleClick);
        window.removeEventListener('mousemove', onMouseMove);
        window.removeEventListener('mouseup', onMouseUp);
        
        clearSelection();
        // ★[マーキー] エディタを閉じる時はモード解除(次回開いた時に意図せずマーキーにならないよう)
        marqueeMode = false; onMarqueeConfirm = null; clearMarquee();
        if (resizeGroup && resizeGroup.parentNode) {
            resizeGroup.parentNode.removeChild(resizeGroup);
        }
        currentSvg = null;
        resizeGroup = null;
    }

    // --- ハンドル作成 ---
    function createHandleGroup() {
        if (currentSvg.querySelector('#inlineResizeHandles')) return;

        const ns = "http://www.w3.org/2000/svg";
        resizeGroup = document.createElementNS(ns, "g");
        resizeGroup.id = "inlineResizeHandles";
        resizeGroup.style.display = "none";

        // 枠線
        const outline = document.createElementNS(ns, "rect");
        outline.id = "inlineResizeOutline";
        outline.setAttribute("fill", "none");
        outline.setAttribute("stroke", "#4285F4");
        outline.setAttribute("stroke-width", "1");
        outline.setAttribute("stroke-dasharray", "4");
        outline.style.pointerEvents = "none";
        resizeGroup.appendChild(outline);

        // 8方向ハンドル
        const handles = [
            { id: 'nw', cursor: 'nw-resize' }, { id: 'n', cursor: 'n-resize' }, { id: 'ne', cursor: 'ne-resize' },
            { id: 'w', cursor: 'w-resize' },                                   { id: 'e', cursor: 'e-resize' },
            { id: 'sw', cursor: 'sw-resize' }, { id: 's', cursor: 's-resize' }, { id: 'se', cursor: 'se-resize' }
        ];

        handles.forEach(h => {
            const rect = document.createElementNS(ns, "rect");
            rect.setAttribute("width", "10");
            rect.setAttribute("height", "10");
            rect.setAttribute("fill", "#fff");
            rect.setAttribute("stroke", "#4285F4");
            rect.setAttribute("stroke-width", "1");
            rect.style.cursor = h.cursor;
            rect.dataset.handle = h.id;
            rect.classList.add('inline-resize-handle');
            resizeGroup.appendChild(rect);
        });

        currentSvg.appendChild(resizeGroup);
    }

    // --- 座標・変形ヘルパー ---
    function getLocalPoint(evt) {
        const pt = currentSvg.createSVGPoint();
        pt.x = evt.clientX;
        pt.y = evt.clientY;
        return pt.matrixTransform(currentSvg.getScreenCTM().inverse());
    }

    function parseTransform(el) {
        const t = el.getAttribute('transform') || '';
        let x = 0, y = 0, sx = 1, sy = 1;
        const tm = t.match(/translate\(([^,]+)(?:[,\s]+([^)]+))?\)/);
        if (tm) { x = parseFloat(tm[1])||0; y = parseFloat(tm[2])||0; }
        const sm = t.match(/scale\(([^)]+)\)/);
        if (sm) { 
            const p = sm[1].split(/[,\s]+/).map(parseFloat);
            sx = p[0]||1; sy = (p[1]!==undefined)?p[1]:sx;
        }
        return { x, y, scaleX: sx, scaleY: sy };
    }

    function setTransform(el, x, y, sx, sy) {
        el.setAttribute('transform', `translate(${x}, ${y}) scale(${sx}, ${sy})`);
    }

    // --- 選択・更新処理 ---
    function select(el) {
        // 編集中の確定
        if (isEditing && editingTextEl !== el) finishTextEditing();

        if (selectedEl && selectedEl !== el) {
            selectedEl.classList.remove('svg-editor-selected');
        }
        
        selectedEl = el;
        selectedEl.classList.add('svg-editor-selected');
        
        updateHandles();
        
        // ▼▼▼ [修正] 要素のDOM移動（最前面化）を廃止 ▼▼▼
        // 代わりにハンドルグループだけを最前面に持ってくる
        if (resizeGroup && resizeGroup.parentNode === currentSvg) {
            currentSvg.appendChild(resizeGroup); 
        }
        // ▲▲▲ [修正] ここまで ▲▲▲
    }

    function clearSelection() {
        if (selectedEl) {
            selectedEl.classList.remove('svg-editor-selected');
            selectedEl = null;
        }
        if (resizeGroup) resizeGroup.style.display = "none";
    }

    function updateHandles() {
        if (!selectedEl || !resizeGroup) return;

        const t = parseTransform(selectedEl);
        let bbox;
        try { bbox = selectedEl.getBBox(); } catch(e) { return; }

        const baseX = parseFloat(selectedEl.getAttribute('x') || 0);
        const baseY = parseFloat(selectedEl.getAttribute('y') || 0);

        let finalX = t.x + (baseX * t.scaleX) + (bbox.x * t.scaleX); 
        let finalY = t.y + (baseY * t.scaleY) + (bbox.y * t.scaleY);
        
        if(selectedEl.tagName !== 'text') {
             finalX = t.x + (baseX * t.scaleX);
             finalY = t.y + (baseY * t.scaleY);
        } else {
             finalX = t.x + bbox.x * t.scaleX;
             finalY = t.y + bbox.y * t.scaleY;
        }

        const w = bbox.width * t.scaleX;
        const h = bbox.height * t.scaleY;

        resizeGroup.style.display = "block";
        
        const outline = resizeGroup.querySelector('#inlineResizeOutline');
        outline.setAttribute('x', finalX);
        outline.setAttribute('y', finalY);
        outline.setAttribute('width', w);
        outline.setAttribute('height', h);

        const handles = Array.from(resizeGroup.querySelectorAll('.inline-resize-handle'));
        const mx = finalX + w/2;
        const my = finalY + h/2;

        handles.forEach(r => {
            const id = r.dataset.handle;
            const pos = {
                nw: [finalX, finalY], n: [mx, finalY], ne: [finalX+w, finalY],
                w: [finalX, my],                       e: [finalX+w, my],
                sw: [finalX, finalY+h], s: [mx, finalY+h], se: [finalX+w, finalY+h]
            };
            if(pos[id]) {
                r.setAttribute('x', pos[id][0] - 5);
                r.setAttribute('y', pos[id][1] - 5);
            }
        });
    }

    // --- マウスイベント ---
    function onMouseDown(e) {
        if (e.button !== 0) return;

        // ★[マーキー] 範囲選択モード: ドラッグで矩形(SVG<rect>)を描く。既存の選択/編集より優先。
        if (marqueeMode) {
            e.preventDefault(); e.stopPropagation();
            if (isEditing) finishTextEditing();
            const p = getLocalPoint(e);
            marqueeStart = { x: p.x, y: p.y };
            clearMarquee();
            const ns = "http://www.w3.org/2000/svg";
            marqueeRectEl = document.createElementNS(ns, "rect");
            marqueeRectEl.setAttribute('id', 'ist_marqueeRect');
            marqueeRectEl.setAttribute('x', p.x); marqueeRectEl.setAttribute('y', p.y);
            marqueeRectEl.setAttribute('width', 0); marqueeRectEl.setAttribute('height', 0);
            marqueeRectEl.setAttribute('fill', 'rgba(239,68,68,0.15)');
            marqueeRectEl.setAttribute('stroke', '#ef4444');
            marqueeRectEl.setAttribute('stroke-width', '2');
            marqueeRectEl.setAttribute('vector-effect', 'non-scaling-stroke');
            marqueeRectEl.style.pointerEvents = 'none';
            currentSvg.appendChild(marqueeRectEl);
            isMarqueeDragging = true;
            return;
        }

        if (isEditing) {
            if (foreignObject && !foreignObject.contains(e.target)) {
                finishTextEditing();
            } else {
                return;
            }
        }

        const target = e.target;
        
        if (target.classList.contains('inline-resize-handle')) {
            e.preventDefault();
            e.stopPropagation();
            isResizing = true;
            activeHandle = target.dataset.handle;
            
            initTransform = parseTransform(selectedEl);
            try { initBBox = selectedEl.getBBox(); } catch(e){}
            dragStartPos = { x: e.clientX, y: e.clientY };
            return;
        }

        if (target !== currentSvg && target.parentNode !== resizeGroup && target !== resizeGroup && target.tagName !== 'foreignObject') {
            e.stopPropagation();
            select(target);
            
            isDragging = true;
            dragStartPos = { x: e.clientX, y: e.clientY };
            initTransform = parseTransform(target);
        }
    }

    function onMouseMove(e) {
        // ★[マーキー] 矩形ドラッグ更新(selectedEl不要なので早期returnより前に置く)
        if (isMarqueeDragging && marqueeRectEl) {
            e.preventDefault();
            const p = getLocalPoint(e);
            const x = Math.min(marqueeStart.x, p.x), y = Math.min(marqueeStart.y, p.y);
            const w = Math.abs(p.x - marqueeStart.x), h = Math.abs(p.y - marqueeStart.y);
            marqueeRectEl.setAttribute('x', x); marqueeRectEl.setAttribute('y', y);
            marqueeRectEl.setAttribute('width', w); marqueeRectEl.setAttribute('height', h);
            return;
        }

        if (!currentSvg || !selectedEl) return;

        if (isResizing) {
            e.preventDefault();
            const pt = getLocalPoint(e);
            const startPt = getLocalPoint({ clientX: dragStartPos.x, clientY: dragStartPos.y });
            
            const dx = pt.x - startPt.x;
            const dy = pt.y - startPt.y;
            
            const oldW = initBBox.width * initTransform.scaleX;
            const oldH = initBBox.height * initTransform.scaleY;
            const ratio = oldW / oldH;
            
            let newW = oldW;
            let newH = oldH;

            if (activeHandle.includes('e')) newW += dx;
            if (activeHandle.includes('w')) newW -= dx;
            if (activeHandle.includes('s')) newH += dy;
            if (activeHandle.includes('n')) newH -= dy;

            if (newW < 10) newW = 10;
            if (newH < 10) newH = 10;

            if (['nw', 'ne', 'sw', 'se'].includes(activeHandle)) {
                if (Math.abs(newW - oldW) > Math.abs((newH - oldH) * ratio)) {
                    newH = newW / ratio;
                } else {
                    newW = newH * ratio;
                }
            }

            const nsx = newW / initBBox.width;
            const nsy = newH / initBBox.height;

            let nx = initTransform.x;
            let ny = initTransform.y;
            const bx = parseFloat(selectedEl.getAttribute('x')||0);
            const by = parseFloat(selectedEl.getAttribute('y')||0);

            if (activeHandle.includes('w')) {
                const right = (initTransform.x + bx * initTransform.scaleX) + oldW;
                nx = right - newW - (bx * nsx);
            }
            if (activeHandle.includes('n')) {
                const bottom = (initTransform.y + by * initTransform.scaleY) + oldH;
                ny = bottom - newH - (by * nsy);
            }
            if (!activeHandle.includes('w')) {
                nx = initTransform.x + bx * (initTransform.scaleX - nsx);
            }
            if (!activeHandle.includes('n')) {
                ny = initTransform.y + by * (initTransform.scaleY - nsy);
            }

            setTransform(selectedEl, nx, ny, nsx, nsy);
            updateHandles();
            return;
        }

        if (isDragging) {
            e.preventDefault();
            const pt = getLocalPoint(e);
            const startPt = getLocalPoint({ clientX: dragStartPos.x, clientY: dragStartPos.y });
            
            const dx = pt.x - startPt.x;
            const dy = pt.y - startPt.y;

            setTransform(selectedEl, initTransform.x + dx, initTransform.y + dy, initTransform.scaleX, initTransform.scaleY);
            updateHandles();
        }
    }

    function onMouseUp(e) {
        // ★[マーキー] 確定: 小さすぎる矩形は破棄、それ以外は確定ハンドラへ(原画像px座標)
        if (isMarqueeDragging) {
            isMarqueeDragging = false;
            if (marqueeRectEl) {
                const x = parseFloat(marqueeRectEl.getAttribute('x')) || 0;
                const y = parseFloat(marqueeRectEl.getAttribute('y')) || 0;
                const w = parseFloat(marqueeRectEl.getAttribute('width')) || 0;
                const h = parseFloat(marqueeRectEl.getAttribute('height')) || 0;
                if (w < 5 || h < 5) {
                    clearMarquee();
                } else {
                    const rect = { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) };
                    if (typeof onMarqueeConfirm === 'function') {
                        try { onMarqueeConfirm(rect, e.clientX, e.clientY); } catch (err) { console.warn('[marquee] confirm失敗', err); clearMarquee(); }
                    } else { clearMarquee(); }
                }
            }
            return;
        }
        isDragging = false;
        isResizing = false;
        activeHandle = null;
    }

    // ★[マーキー] モード切替/解除/状態取得
    function setMarqueeMode(on, confirmCb) {
        marqueeMode = !!on;
        onMarqueeConfirm = confirmCb || null;
        if (currentSvg) currentSvg.style.cursor = marqueeMode ? 'crosshair' : '';
        if (marqueeMode) clearSelection();
        else clearMarquee();
    }
    function clearMarquee() {
        isMarqueeDragging = false;
        if (marqueeRectEl && marqueeRectEl.parentNode) marqueeRectEl.parentNode.removeChild(marqueeRectEl);
        marqueeRectEl = null;
    }
    function isMarqueeMode() { return marqueeMode; }

    function onDoubleClick(e) {
        if (e.target.tagName === 'text') {
            startTextEditing(e.target);
        }
    }

    // --- テキスト編集 (左揃え・右方向拡大版) ---
    function startTextEditing(textEl) {
        if (isEditing) finishTextEditing();
        
        select(textEl);
        isEditing = true;
        editingTextEl = textEl;
        
        const bbox = textEl.getBBox();
        const t = parseTransform(textEl);
        
        const ns = "http://www.w3.org/2000/svg";
        foreignObject = document.createElementNS(ns, "foreignObject");
        
        // 座標計算 (bbox + transform)
        const pad = 4;
        // 左上座標 (ここを固定点とする)
        const ax = (bbox.x * t.scaleX) + t.x;
        const ay = (bbox.y * t.scaleY) + t.y;
        
        // 元の中心座標 (縦書き用)
        const originalCenterY = ay + (bbox.height * t.scaleY) / 2;

        const aw = bbox.width * t.scaleX;
        const ah = bbox.height * t.scaleY;

        foreignObject.setAttribute("x", ax - pad);
        foreignObject.setAttribute("y", ay - pad);
        foreignObject.setAttribute("width", aw + pad*2 + 50); 
        foreignObject.setAttribute("height", ah + pad*2 + 20);

        const textarea = document.createElement("textarea");
        textarea.value = textEl.textContent;
        
        // ★修正: CSSクラスに頼らず、JSで全スタイルを適用 (CSS漏れ対策)
        Object.assign(textarea.style, {
            width: "100%",
            height: "100%",
            border: "none",
            padding: `${pad}px`,
            margin: "0",
            background: "rgba(255, 255, 255, 0.95)",
            resize: "none",
            outline: "2px solid #4285F4",
            overflow: "hidden",
            whiteSpace: "pre",
            boxSizing: "border-box",
            textAlign: "left", // ★変更: 中央揃え(center)から左揃え(left)に変更
            lineHeight: "1.1"
        });
        
        const fs = parseFloat(textEl.getAttribute("font-size")||16) * t.scaleY;
        textarea.style.fontSize = fs + "px";
        textarea.style.color = textEl.getAttribute("fill");
        textarea.style.fontWeight = textEl.getAttribute("font-weight") || "normal";
        textarea.style.fontFamily = textEl.getAttribute("font-family") || "sans-serif";
        
        if (textEl.getAttribute("writing-mode") === "vertical-rl") {
            textarea.style.writingMode = "vertical-rl";
        }

        foreignObject.appendChild(textarea);
        currentSvg.appendChild(foreignObject);
        
        textEl.style.visibility = "hidden";
        
        const autoResize = () => {
            if (textarea.style.writingMode === "vertical-rl") {
                // 縦書きの場合（今回は変更なし、高さ中心維持）
                textarea.style.height = 'auto';
                const newH = Math.max(textarea.scrollHeight, ah + pad*2);
                textarea.style.height = newH + 'px';
                foreignObject.setAttribute("height", newH);
                
                const newY = originalCenterY - (newH / 2);
                foreignObject.setAttribute("y", newY);
            } else {
                // 横書きの場合
                textarea.style.width = 'auto';
                const newW = Math.max(textarea.scrollWidth, aw + pad*2) + 10; 
                textarea.style.width = newW + 'px';
                foreignObject.setAttribute("width", newW);
                
                // ★変更: X座標の再計算（中心補正）を削除し、左端を固定する
                // 以前のコード: const newX = originalCenterX - (newW / 2);
                // 以前のコード: foreignObject.setAttribute("x", newX);
                // 何もしなければ初期位置(ax - pad)のまま右に伸びる
            }
        };

        textarea.focus();
        // 入力イベントでリサイズ発火
        textarea.addEventListener('input', autoResize);
        
        // 初期リサイズ実行
        setTimeout(() => {
            autoResize(); 
        }, 0);

        // ブラーで確定
        textarea.addEventListener("blur", finishTextEditing);
    }

    function finishTextEditing() {
        if (!isEditing || !editingTextEl || !foreignObject) return;
        
        const textarea = foreignObject.querySelector("textarea");
        if (textarea) {
            const val = textarea.value;
            editingTextEl.textContent = val;
            if (editingTextEl.dataset.ocrText) {
                editingTextEl.dataset.ocrText = val;
            }
        }
        
        editingTextEl.style.visibility = "visible";
        if (foreignObject.parentNode) foreignObject.parentNode.removeChild(foreignObject);
        
        foreignObject = null;
        editingTextEl = null;
        isEditing = false;
        
        updateHandles();
    }
    
    // フォントサイズ調整 (幅に合わせて拡大・縮小)
    function adjustTextFontSize(textEl) {
        if (!textEl.dataset.boxWidth || !textEl.dataset.boxHeight) return;
        
        const isVert = textEl.dataset.ocrVertical === "true";
        const limW = parseFloat(textEl.dataset.boxWidth);
        const limH = parseFloat(textEl.dataset.boxHeight);
        const currentSize = parseFloat(textEl.getAttribute('font-size'));
        
        try {
            const bb = textEl.getBBox();
            let s = 1;
            if (isVert) {
                if (bb.height > 0) s = limH / bb.height;
            } else {
                if (bb.width > 0) s = limW / bb.width;
            }

            if (s > 0 && s !== 1 && isFinite(s)) {
                textEl.setAttribute('font-size', currentSize * s);
            }
        } catch(e){
            console.error("adjustTextFontSize error:", e);
        }
    }

    return {
        init: init,
        cleanup: cleanup,
        adjustTextFontSize: adjustTextFontSize,
        // ★[マーキー] 範囲選択→テキスト化 用API
        setMarqueeMode: setMarqueeMode,
        clearMarquee: clearMarquee,
        isMarqueeMode: isMarqueeMode,
        startTextEditing: startTextEditing,
        select: select
    };
})();



/* ▼▼▼ [完全版] 画像分解ツール制御モジュール (軽量化・キャッシュ主導版) ▼▼▼ */
const ImageSplitterTool = (function() {
    let modal, canvasContainer, thresholdSlider, thresholdValue, reprocessBtn, 
        undoBtn, redoBtn, showBordersCheck, reflectBtn, closeBtn, loadingOverlay, loadingText;
    
    // 追加ボタン
    let ocrBtn;
    let convertAllBtn;
    
    // オプション・状態
    let flattenCheck;
    let resetBtn;
    // ★[要望3] ヘッダー改修: 1枚反映ボタン・前/次ナビ・分数表示・スコープ切替
    let flattenBtn, prevImgBtn, nextImgBtn, pageIndicator, scopeToggle;
    let actionScope = 'current'; // 'current'(このページのみ／既定) | 'all'(全ページ)
    let navList = [];            // 画像を持つ {slideIndex,key} の並び一覧
    let navPos = -1;             // navList内の現在位置
    let lastResultData = null;
    let currentOcrData = null; 

    let currentBase64Image = null; 
    let currentImgElement = null;  
    let targetSlideIndex = -1;
    let targetImageKey = "";
    
    // コンテキストメニュー
    let contextMenu = null;
    let contextMenuTarget = null;
    
    // 履歴管理
    let historyStack = [];
    let historyIndex = -1;

    // ★★★ [追加] セッションキャッシュ (スライドID_画像キー をキーにした保存領域) ★★★
    const sessionCache = {};

    function init() {
        modal = document.getElementById('imageSplitterModal');
        canvasContainer = document.getElementById('ist_canvasContainer');
        
        // ▼▼▼ [修正] 削除された要素への参照を削除 ▼▼▼
        // thresholdSlider = document.getElementById('ist_thresholdSlider');
        // thresholdValue = document.getElementById('ist_thresholdValue');
        // showBordersCheck = document.getElementById('ist_showBordersCheck');
        // ocrBtn = document.getElementById('ist_ocrBtn');
        // resetBtn = document.getElementById('ist_resetBtn');
        // ▲▲▲ [修正] ここまで ▲▲▲

        reprocessBtn = document.getElementById('ist_reprocessBtn'); // 再解析ボタン
        undoBtn = document.getElementById('ist_undoBtn');
        redoBtn = document.getElementById('ist_redoBtn');

        closeBtn = document.getElementById('ist_closeBtn');
        loadingOverlay = document.getElementById('ist_loadingOverlay');
        loadingText = document.getElementById('ist_loadingText');

        flattenCheck = document.getElementById('ist_flattenCheck'); // 旧チェックボックス(廃止。残存時のみnull回避で参照)
        convertAllBtn = document.getElementById('ist_convertAllBtn');
        // ★[要望3] 新ヘッダー要素
        flattenBtn    = document.getElementById('ist_flattenBtn');
        prevImgBtn    = document.getElementById('ist_prevImgBtn');
        nextImgBtn    = document.getElementById('ist_nextImgBtn');
        pageIndicator = document.getElementById('ist_pageIndicator');
        scopeToggle   = document.getElementById('ist_scopeToggle');

        if(!modal) return;

        // イベントリスナー
        closeBtn.onclick = close;

        // ★[要望3] 各アクションはスコープ(このページのみ/全ページ)対応のディスパッチャ経由
        reprocessBtn.onclick = () => runAction('reprocess');
        if (convertAllBtn) convertAllBtn.onclick = () => runAction('convertAll');
        // ★[新規H] 一括画像変換ボタン
        var revertAllBtn = document.getElementById('ist_revertAllBtn');
        if (revertAllBtn) revertAllBtn.onclick = () => runAction('revertAll');
        if (flattenBtn) flattenBtn.onclick = () => runAction('flatten');

        // ★[マーキー] 範囲をテキスト化トグル: ONでドラッグ矩形→convertMarqueeToText、再押下でOFF。
        var marqueeBtn = document.getElementById('ist_marqueeBtn');
        if (marqueeBtn) {
            marqueeBtn.onclick = function () {
                if (typeof InlineSvgEditor === 'undefined' || !InlineSvgEditor.setMarqueeMode) return;
                var on = !(InlineSvgEditor.isMarqueeMode && InlineSvgEditor.isMarqueeMode());
                InlineSvgEditor.setMarqueeMode(on, convertMarqueeToText);
                // ON表示(枠を強調)。OFFで戻す。
                marqueeBtn.style.outline = on ? '2px solid #ef4444' : '';
                marqueeBtn.style.outlineOffset = on ? '1px' : '';
                if (on) showStatus('ドラッグで範囲を囲むとテキストに変換します（もう一度押すと解除）', 'info', 4000);
            };
        }

        // Undo/Redo
        undoBtn.onclick = undo;
        redoBtn.onclick = redo;

        // ★[要望3] 前/次の画像ナビ
        if (prevImgBtn) prevImgBtn.onclick = () => navigate(-1);
        if (nextImgBtn) nextImgBtn.onclick = () => navigate(1);

        // ★[要望3] スコープ切替(セグメント)。既定=このページのみ
        if (scopeToggle) {
            scopeToggle.querySelectorAll('.ist-scope-btn').forEach(btn => {
                btn.onclick = () => {
                    actionScope = btn.dataset.scope; // 'current' | 'all'
                    scopeToggle.querySelectorAll('.ist-scope-btn').forEach(b => {
                        const on = b.dataset.scope === actionScope;
                        // ★[新規H] 黒テーマ配色
                        b.style.background = on ? '#4b5563' : 'transparent';
                        b.style.color = on ? '#fff' : '#9ca3af';
                        b.style.boxShadow = on ? '0 1px 2px rgba(0,0,0,0.3)' : 'none';
                    });
                };
            });
        }

        // コンテキストメニュー初期化
        setupContextMenu();
    }

    // ★ [追加] 処理中の元画像を表示する関数
    function renderLoadingPreview(base64) {
        canvasContainer.innerHTML = '';
        const img = document.createElement('img');
        img.src = base64;
        // 横幅いっぱいに合わせ、高さは自動調整（余白なし）
        img.style.width = '100%';
        img.style.height = 'auto';
        img.style.display = 'block'; // 下部の隙間対策
        img.style.opacity = '0.5';   // 処理中らしく少し薄くする
        canvasContainer.appendChild(img);
    }

    // ★★★ [修正] open関数: 即時プレビュー表示を追加 ★★★
    function open(base64Image, slideIndex, key) {
        if (!modal) init();

        targetSlideIndex = slideIndex;
        targetImageKey = key;

        // ★[要望3] 前/次ナビ一覧と「何枚目/全体」分数表示を更新(付帯機能。失敗しても分解は継続)
        try { buildNavList(); updateNav(); } catch (e) { console.warn('[ist] nav更新失敗', e); }

        modal.style.display = 'flex'; // 先に表示
        
        // ▼▼▼ [追加] まず元画像をプレビュー表示（処理中状態） ▼▼▼
        // キャッシュやデータがある場合でも、読み込み処理の一瞬の間をつなぐために表示
        // (dataオブジェクト内の originalData があればそれを優先、なければ引数の base64Image)
        let previewImage = base64Image;
        if (typeof SlideEditor !== 'undefined') {
             const sd = SlideEditor.getSlideData();
             if (sd && sd[slideIndex] && sd[slideIndex][key] && sd[slideIndex][key].originalData) {
                 previewImage = sd[slideIndex][key].originalData;
             }
        }
        renderLoadingPreview(previewImage);
        // ▲▲▲ [追加] ここまで ▲▲▲
        
        // 1. キャッシュの確認
        const cacheKey = `${slideIndex}_${key}`;
        if (sessionCache[cacheKey]) {
            console.log("ImageSplitterTool: キャッシュから復元");
            const cache = sessionCache[cacheKey];
            
            // 変数の復元
            historyStack = [...cache.historyStack];
            historyIndex = cache.historyIndex;
            currentOcrData = cache.currentOcrData;
            currentBase64Image = cache.currentBase64Image;
            
            // ★追加: preLoadで作られたデータがある場合、それを履歴の初期状態として復元する
            if (cache.preloadedData && (!cache.historyStack || cache.historyStack.length === 0)) {
                console.log("ImageSplitterTool: プリロード済みデータを展開します");
                const img = new Image();
                img.onload = () => { 
                    currentImgElement = img; 
                    // modal.style.display = 'flex'; // ← 削除 (冒頭で表示済み)
                    renderResult(cache.preloadedData); // プリロード結果を描画
                    saveHistory(); // 初期履歴として保存
                    
                    // キャッシュ内の履歴も更新しておく（次回のために）
                    cache.historyStack = [...historyStack];
                    cache.historyIndex = historyIndex;
                    delete cache.preloadedData; // もう不要
                    
                    if(flattenCheck) flattenCheck.checked = cache.isFlatten;
                };
                img.src = currentBase64Image;
                return;
            }

            // 画像要素の復元
            const img = new Image();
            img.onload = () => { 
                currentImgElement = img; 
                // modal.style.display = 'flex'; // ← 削除 (冒頭で表示済み)
                
                restoreHistory(); 
                
                if(flattenCheck) flattenCheck.checked = cache.isFlatten;
            };
            img.src = currentBase64Image;
            return; 
        }

        // --- 以下、キャッシュがない場合の通常の初期化処理 ---

        if(flattenCheck) flattenCheck.checked = false; 
        currentOcrData = null; 
        historyStack = []; // リセット
        historyIndex = -1; // リセット
        
        // 既存データの確認 (再編集)
        let existingData = null;
        if (typeof SlideEditor !== 'undefined') {
            const slideData = SlideEditor.getSlideData();
            if (slideData && slideData[slideIndex]) {
                const val = slideData[slideIndex][key];
                // data または originalData のいずれかがあればOK
                if (val && typeof val === 'object' && (val.originalData || val.data)) {
                    existingData = val;
                }
            }
        }

        // modal.style.display = 'flex'; // ← 削除 (冒頭で表示済み)

        if (existingData) {
            console.log("ImageSplitterTool: 既存データが見つかりました。状態を確認します...");
            // 元画像データの取得 (originalData優先)
            currentBase64Image = existingData.originalData || existingData.data;
            
            // ▼▼▼ [追加] データから画像を再表示（引数と違う場合があるため） ▼▼▼
            renderLoadingPreview(currentBase64Image); 

            // レイヤー情報が「完全な状態（編集中）」かチェック
            let isSessionData = false;
            // レイヤー配列が存在し、かつ中身がある場合
            if (existingData.layers && Array.isArray(existingData.layers) && existingData.layers.length > 0) {
                const firstLayer = existingData.layers[0];
                const imgData = firstLayer.data || firstLayer.key || firstLayer.dataBase64;
                
                if (imgData && typeof imgData === 'string' && imgData.startsWith('data:image/')) {
                    isSessionData = true;
                }
                // ★[修正] テキスト変換済みレイヤーを含む場合も「編集中の完全なデータ」として扱い、
                //   再分解(再OCR=画像へ戻る)せず既存レイヤー(テキスト含む)をそのまま描画する。
                //   (全ページ一括テキスト変換のlite経路は sessionCache を破棄するため、ここで変換結果が再描画される)
                else if (existingData.layers.some(l => l && l.type === 'text')) {
                    isSessionData = true;
                }
            }

            // 画像ロード待機
            const img = new Image();
            img.onload = () => { 
                currentImgElement = img; 
                
                // 既存データが「編集中の完全なデータ」か「履歴データ（画像省略版）」かで分岐
                if (isSessionData) {
                    console.log("ImageSplitterTool: 編集中のデータを復元します");
                    const restoreData = {
                        originalWidth: existingData.originalWidth || 800,
                        originalHeight: existingData.originalHeight || 600,
                        background: existingData.backgroundImage,
                        layers: existingData.layers.map(l => ({
                            dataBase64: l.data || l.key || l.dataBase64, 
                            x: l.x, y: l.y, width: l.w || l.width, height: l.h || l.height,
                            ocrData: l.ocrData,
                            type: l.type || 'image',
                            text: l.text,
                            color: l.color,
                            fontSize: l.fontSize,
                            fontWeight: l.fontWeight,
                            fontFamily: l.fontFamily,
                            isVertical: l.isVertical
                        }))
                    };

                    if (existingData.ocrCache) {
                        currentOcrData = existingData.ocrCache;
                    }

                    renderResult(restoreData);
                    saveHistory(); 
                } else {

                    if (existingData.ocrCache && (existingData.ocrCacheEngine || 'legacy') === MajinSplitAdapter.resolveMode()) {
                        console.log("ImageSplitterTool: 保存されたOCRキャッシュ(同一エンジン)を使用して高速展開します");
                        currentOcrData = existingData.ocrCache;

                        // OCR済みの状態から分解処理(runProcess)だけを実行
                        runProcess();

                    } else {
                        console.log("ImageSplitterTool: OCRキャッシュ無し/別エンジンのため、元画像から再分解・OCRを実行します");
                        runOCRProcess();
                    }

                }
            };
            img.src = currentBase64Image;

        } else {
            console.log("ImageSplitterTool: 新規画像を分解します (自動OCR開始)");
            currentBase64Image = base64Image;
            const img = new Image();
            img.onload = () => {
                currentImgElement = img;
                runOCRProcess();
            };
            img.src = base64Image;
        }
    }

    // ★[要望3] 画像を持つ {slideIndex,key} の並び一覧(image/backgroundImage/foregroundImage順)を作る
    function buildNavList() {
        navList = [];
        if (typeof SlideEditor === 'undefined') return;
        const sd = SlideEditor.getSlideData();
        if (!Array.isArray(sd)) return;
        const imageKeys = ['image', 'backgroundImage', 'foregroundImage'];
        for (let i = 0; i < sd.length; i++) {
            if (!sd[i]) continue;
            for (const k of imageKeys) {
                const v = sd[i][k];
                if (!v) continue;
                let has = false;
                if (typeof v === 'string') { has = v.startsWith('data:image/'); }
                else if (typeof v === 'object') {
                    has = (v.data && String(v.data).startsWith('data:image/'))
                       || (v.originalData && String(v.originalData).startsWith('data:image/'))
                       || (Array.isArray(v.layers) && v.layers.length > 0);
                }
                if (has) navList.push({ slideIndex: i, key: k });
            }
        }
    }

    // ★[要望3] 「何枚目 / 全体」分数表示と前/次ボタンの活性状態を更新
    function updateNav() {
        navPos = navList.findIndex(n => n.slideIndex === targetSlideIndex && n.key === targetImageKey);
        const total = navList.length;
        const cur = navPos >= 0 ? navPos + 1 : 1;
        if (pageIndicator) pageIndicator.textContent = `${cur} / ${total || 1}`;
        const canPrev = navPos > 0;
        const canNext = navPos >= 0 && navPos < total - 1;
        if (prevImgBtn) { prevImgBtn.disabled = !canPrev; prevImgBtn.style.opacity = canPrev ? '1' : '0.45'; prevImgBtn.style.cursor = canPrev ? 'pointer' : 'default'; }
        if (nextImgBtn) { nextImgBtn.disabled = !canNext; nextImgBtn.style.opacity = canNext ? '1' : '0.45'; nextImgBtn.style.cursor = canNext ? 'pointer' : 'default'; }
    }

    // ★[要望3] 現在の編集状態をキャッシュへ保存(close()と共有。モーダルは閉じない)
    function saveCurrentToCache() {
        try {
            if (targetSlideIndex === -1 || !currentBase64Image) return;
            const cacheKey = `${targetSlideIndex}_${targetImageKey}`;
            const currentSvg = canvasContainer.querySelector('svg');
            if (currentSvg && (historyStack.length === 0 || historyStack[historyIndex] !== currentSvg.outerHTML)) {
                if (historyIndex < historyStack.length - 1) historyStack.splice(historyIndex + 1);
                historyStack.push(currentSvg.outerHTML);
                historyIndex = historyStack.length - 1;
            }
            sessionCache[cacheKey] = {
                historyStack: [...historyStack],
                historyIndex: historyIndex,
                currentOcrData: currentOcrData,
                currentBase64Image: currentBase64Image,
                isFlatten: flattenCheck ? flattenCheck.checked : false,
                lastSvgState: currentSvg ? currentSvg.outerHTML : null
            };
            if (typeof SlideEditor !== 'undefined') {
                const currentData = SlideEditor.getSlideData();
                if (currentData && currentData[targetSlideIndex]) {
                    const target = currentData[targetSlideIndex][targetImageKey];
                    if (typeof target !== 'object' || target === null) {
                        currentData[targetSlideIndex][targetImageKey] = { data: currentBase64Image, originalData: currentBase64Image };
                    }
                    currentData[targetSlideIndex][targetImageKey]._cached = true;
                }
            }
        } catch (e) { console.error("saveCurrentToCache failed:", e); }
    }

    // ★[要望3] 前(-1)/次(+1)の画像へ移動。現在の編集はキャッシュ保存してから切替
    function navigate(dir) {
        if (navPos < 0) { buildNavList(); updateNav(); }
        const next = navPos + dir;
        if (next < 0 || next >= navList.length) return;
        saveCurrentToCache(); // 現在ページの編集を退避(target変更前に実行)
        const t = navList[next];
        const sd = (typeof SlideEditor !== 'undefined') ? SlideEditor.getSlideData() : null;
        let base = '';
        if (sd && sd[t.slideIndex]) {
            const v = sd[t.slideIndex][t.key];
            base = (typeof v === 'string') ? v : (v && (v.originalData || v.data)) || '';
        }
        open(base, t.slideIndex, t.key);
    }

    // ★[要望3] 対象画像をツールへ読み込み、分解結果(SVG)が描画されるまで待つ(全ページ処理用)
    function openAndWait(slideIndex, key, timeoutMs) {
        return new Promise((resolve) => {
            const sd = (typeof SlideEditor !== 'undefined') ? SlideEditor.getSlideData() : null;
            let base = '';
            if (sd && sd[slideIndex]) {
                const v = sd[slideIndex][key];
                base = (typeof v === 'string') ? v : (v && (v.originalData || v.data)) || '';
            }
            open(base, slideIndex, key);
            const start = Date.now();
            const limit = timeoutMs || 45000;
            const iv = setInterval(() => {
                const svg = canvasContainer.querySelector('svg');
                const loading = loadingOverlay && loadingOverlay.style.display === 'flex';
                if ((svg && !loading) || (Date.now() - start) > limit) { clearInterval(iv); resolve(!!svg); }
            }, 150);
        });
    }

    // ★[要望3] スコープ対応アクションのディスパッチ。current=このページのみ(既定) / all=全ページ
    async function runAction(kind) {
        if (actionScope !== 'all') {
            if (kind === 'reprocess')  return runProcess();
            if (kind === 'convertAll') return convertAllTextLayers();
            if (kind === 'revertAll')  return revertAllTextLayers(); // [新規H] 一括画像変換
            if (kind === 'flatten')    return reflectToSlide(false, true); // 1枚画像化してslideDataへ反映+閉じる
            return;
        }
        return runActionAllPages(kind);
    }

    // ★[要望3] 全ページに同一アクションを適用(各ページをopen→処理→slideDataへ反映)
    async function runActionAllPages(kind) {
        // ★[新規O] 全ページ「一括テキスト変換」は、エディタ起動/OCR/再分解を伴わず、
        //   slideData の分解済みレイヤー(ocrData)を直接 type:'text' へ書き換える軽量パスで実行する(大幅高速化)。
        if (kind === 'convertAll') { return convertAllPagesTextLite(); }
        // ★[表示切替] 一括画像変換も無処理(lite)で text→image に戻すだけ(再分解/OCRを走らせない)
        if (kind === 'revertAll') { return convertAllPagesImageLite(); }
        buildNavList();
        const list = navList.slice();
        if (!list.length) { showStatus('対象の画像がありません', 'info'); return; }
        const label = kind === 'reprocess' ? '再解析' : (kind === 'convertAll' ? '一括テキスト変換' : (kind === 'revertAll' ? '一括画像変換' : '1枚画像化'));
        if (!confirm(`全 ${list.length} 枚の画像に「${label}」を実行します。よろしいですか？`)) return;
        showLoading(true, `全ページ処理中... (0/${list.length})`);
        try {
            for (let p = 0; p < list.length; p++) {
                const t = list[p];
                if (loadingText) loadingText.textContent = `全ページ処理中... (${p + 1}/${list.length})`;
                const ok = await openAndWait(t.slideIndex, t.key);
                if (!ok) { console.warn('[ist] 全ページ処理: 読込タイムアウト', t); continue; }
                try {
                    if (kind === 'reprocess') { await runProcess(); }
                    else if (kind === 'convertAll') { convertAllTextLayers(); }
                    else if (kind === 'revertAll') { revertAllTextLayers(); }
                    // flatten は reflectToSlide(forceFlatten=true) 側で実施
                    // 各ページの結果を slideData へ書き戻す(batch=true: エディタ再構築/ナビ/サーバ更新/閉じる をしない)
                    await reflectToSlide(false, kind === 'flatten', true);
                } catch (ePage) { console.warn('[ist] 全ページ処理失敗', t, ePage); }
                await new Promise(r => setTimeout(r, 0));
            }
            // 反映後にエディタを一度だけ再構築
            if (typeof SlideEditor !== 'undefined') {
                const sd2 = SlideEditor.getSlideData();
                const ta = document.getElementById('slideDataInput');
                if (ta && sd2) ta.value = JSON.stringify(sd2, null, 2);
                if (SlideEditor.isEditorActive()) SlideEditor.renderEditorUI();
            }
            showStatus(`全ページに「${label}」を適用しました`, 'success');
        } catch (e) {
            console.error('[ist] 全ページ処理エラー', e);
            showStatus('全ページ処理でエラーが発生しました: ' + (e && e.message ? e.message : e), 'error');
        } finally {
            showLoading(false);
            close();
        }
    }

    // ★★★ [修正] close関数: 即時非表示 & バックグラウンド保存 ★★★
    function close() {
        // 1. まずUIを即座に消す
        if (modal) modal.style.display = 'none';
        if (contextMenu) contextMenu.style.display = 'none';
        if (typeof InlineSvgEditor !== 'undefined') InlineSvgEditor.cleanup();

        // 2. 重い処理（キャッシュ保存とDOMクリア）をバックグラウンドへ
        setTimeout(async () => {
            try {
                // ★[新規N] 閉じる前に、テキスト変換した編集内容を slideData へ永続化する。
                //   SVGがまだDOMに生きているうちに reflectToSlide(batch=true) で layers(type:'text'含む)を slideData へ書く。
                //   これによりテキスト変換した領域が、スライド生成時に画像ではなくテキストとして配置される。
                //   ★F1対策: SVG内に <text> 要素(=テキスト変換が行われた証跡)がある時だけ実行する。
                //     未編集の「見ただけで閉じた」ケースで slide.type が fullImage→splitImage に変わる等の副作用を防ぐ。
                try {
                    const __istSvg = canvasContainer && canvasContainer.querySelector('svg');
                    if (__istSvg && __istSvg.querySelector('text')) {
                        await reflectToSlide(false, false, true);
                    }
                } catch (eRefl) { console.warn('[ist] close時の編集永続化(reflectToSlide batch)に失敗:', eRefl); }
                // エディタをクリーンアップする前に状態をキャッシュへ保存(saveCurrentToCacheと共有)
                saveCurrentToCache();
                // エディタの表示更新（解析済みアイコンなどを出すため）
                if (typeof SlideEditor !== 'undefined' && SlideEditor.isEditorActive()) {
                    SlideEditor.renderEditorUI();
                }

                // 3. DOMと変数のリセット
                canvasContainer.innerHTML = '';
                historyStack = [];
                historyIndex = -1;
                lastResultData = null;
                currentOcrData = null;

            } catch (e) {
                console.error("Background cache save failed:", e);
            }
        }, 0); // 次のイベントループで実行
    }

    // --- コンテキストメニュー (右クリック) ---
    function setupContextMenu() {
        if (!document.getElementById('ist_contextMenu')) {
            const menu = document.createElement('div');
            menu.id = 'ist_contextMenu';
            menu.style.cssText = `
                position: fixed; background: white; border: 1px solid #D1D5DB;
                box-shadow: 0 4px 6px rgba(0,0,0,0.1); border-radius: 4px;
                padding: 5px 0; z-index: 12000; display: none; min-width: 150px;
                font-family: sans-serif;
            `;
            menu.innerHTML = `
                <div class="ist-menu-item" id="ist_menuConvert" style="padding: 8px 15px; cursor: pointer; font-size: 14px; color: #333; display:none;">
                    テキストに変換
                </div>
                <div class="ist-menu-item" id="ist_menuRevert" style="padding: 8px 15px; cursor: pointer; font-size: 14px; color: #333; display:none;">
                    画像に戻す
                </div>
            `;
            document.body.appendChild(menu);
            
            const items = menu.querySelectorAll('.ist-menu-item');
            items.forEach(item => {
                item.onmouseover = () => item.style.backgroundColor = '#F3F4F6';
                item.onmouseout = () => item.style.backgroundColor = 'white';
            });
            
            document.getElementById('ist_menuConvert').onclick = () => {
                if (contextMenuTarget) {
                    convertImageToText(contextMenuTarget);
                    saveHistory();
                    contextMenu.style.display = 'none';
                }
            };

            document.getElementById('ist_menuRevert').onclick = () => {
                if (contextMenuTarget) {
                    revertTextToImage(contextMenuTarget);
                    saveHistory();
                    contextMenu.style.display = 'none';
                }
            };
            
            contextMenu = menu;
        }
        
        document.addEventListener('click', () => {
            if (contextMenu) contextMenu.style.display = 'none';
        });
        
        canvasContainer.addEventListener('contextmenu', (e) => {
            e.preventDefault();
            const target = e.target;
            
            if (target.tagName === 'image' && target.dataset.ocrText) {
                contextMenuTarget = target;
                document.getElementById('ist_menuConvert').style.display = 'block';
                document.getElementById('ist_menuRevert').style.display = 'none';
                
                contextMenu.style.top = `${e.clientY}px`;
                contextMenu.style.left = `${e.clientX}px`;
                contextMenu.style.display = 'block';
            } 
            else if (target.tagName === 'text' && target.dataset.originalHref) {
                contextMenuTarget = target;
                document.getElementById('ist_menuConvert').style.display = 'none';
                document.getElementById('ist_menuRevert').style.display = 'block';

                contextMenu.style.top = `${e.clientY}px`;
                contextMenu.style.left = `${e.clientX}px`;
                contextMenu.style.display = 'block';
            }
            else {
                if (contextMenu) contextMenu.style.display = 'none';
            }
        });
    }

    // --- テキスト変換ロジック ---
    function convertImageToText(imageEl) {
        const text = imageEl.dataset.ocrText;
        let color = {r:0, g:0, b:0};
        try {
            if (imageEl.dataset.ocrColor) {
                color = JSON.parse(imageEl.dataset.ocrColor);
            }
        } catch(e) {}

        const x = parseFloat(imageEl.getAttribute('x'));
        const y = parseFloat(imageEl.getAttribute('y'));
        const w = parseFloat(imageEl.getAttribute('width'));
        const h = parseFloat(imageEl.getAttribute('height'));
        
        const originalHref = imageEl.getAttribute('href') || imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href");

        const isVertical = imageEl.dataset.ocrVertical === "true";
        const svgNS = "http://www.w3.org/2000/svg";
        const textEl = document.createElementNS(svgNS, "text");
        
        textEl.textContent = text;
        textEl.setAttribute('fill', `rgb(${color.r}, ${color.g}, ${color.b})`);
        textEl.setAttribute('font-weight', 'bold');
        textEl.setAttribute('font-family', 'sans-serif'); 
        
        let fontSize = 0;
        const lastFontSize = imageEl.dataset.lastFontSize ? parseFloat(imageEl.dataset.lastFontSize) : null;
        let useSavedSize = false;

        if (isVertical) {
            textEl.setAttribute('writing-mode', 'vertical-rl');
            textEl.setAttribute('x', x + w / 2);
            textEl.setAttribute('y', y + h / 2);
            
            if (lastFontSize) {
                fontSize = lastFontSize;
                useSavedSize = true;
            } else {
                fontSize = w * 0.8; 
            }
            textEl.setAttribute('font-size', fontSize);
            textEl.setAttribute('dominant-baseline', 'central');
            textEl.setAttribute('text-anchor', 'middle');
        } else {
            textEl.setAttribute('x', x);
            textEl.setAttribute('y', y + h / 2);
            
            if (lastFontSize) {
                fontSize = lastFontSize;
                useSavedSize = true;
            } else {
                fontSize = h * 0.8; 
            }
            
            textEl.setAttribute('font-size', fontSize);
            textEl.setAttribute('dominant-baseline', 'central');
            textEl.setAttribute('text-anchor', 'start');
        }
        
        textEl.setAttribute('class', 'ist-layer-object ist-text-layer svg-editor-selected');
        
        textEl.dataset.ocrText = text;
        textEl.dataset.baseFontSize = fontSize;
        textEl.dataset.boxWidth = w;
        textEl.dataset.boxHeight = h;
        textEl.dataset.ocrVertical = isVertical;
        textEl.dataset.ocrColor = JSON.stringify(color);
        if(imageEl.dataset.ocrRaw) textEl.dataset.ocrRaw = imageEl.dataset.ocrRaw;
        if(imageEl.dataset.ocrId) textEl.dataset.ocrId = imageEl.dataset.ocrId;

        textEl.dataset.originalHref = originalHref;
        textEl.dataset.originalX = x;
        textEl.dataset.originalY = y;
        textEl.dataset.originalW = w;
        textEl.dataset.originalH = h;

        imageEl.parentNode.replaceChild(textEl, imageEl);
        
        if (!useSavedSize) {
            if (typeof InlineSvgEditor !== 'undefined' && InlineSvgEditor.adjustTextFontSize) {
                 InlineSvgEditor.adjustTextFontSize(textEl);
            }
        }
    }

    // ============================================================
    // ★[マーキー] 範囲を四角で囲んで確定→その範囲を新しいテキスト領域にする機能
    //   (参考: NotebookLM Image Editor の convertMarqueeToTextElement を まじんのSVG/OCRへ移植)
    // ============================================================
    // 矩形領域(原画像px)を currentImgElement から切り出して canvas で返す
    function cropRegionCanvas(rect) {
        if (!currentImgElement) return null;
        const iw = currentImgElement.naturalWidth || currentImgElement.width;
        const ih = currentImgElement.naturalHeight || currentImgElement.height;
        // rectはSVG viewBox座標(=レイヤー座標系)。元画像の自然サイズと異なる場合は比でスケール。
        const svg = canvasContainer && canvasContainer.querySelector('svg');
        let vbW = iw, vbH = ih;
        if (svg) { const vb = (svg.getAttribute('viewBox') || '').split(/\s+/).map(parseFloat); if (vb.length === 4 && vb[2] > 0 && vb[3] > 0) { vbW = vb[2]; vbH = vb[3]; } }
        const sx = iw / vbW, sy = ih / vbH;
        const x = Math.max(0, Math.min(iw - 1, Math.round(rect.x * sx)));
        const y = Math.max(0, Math.min(ih - 1, Math.round(rect.y * sy)));
        const w = Math.max(1, Math.min(iw - x, Math.round(rect.width * sx)));
        const h = Math.max(1, Math.min(ih - y, Math.round(rect.height * sy)));
        const c = document.createElement('canvas');
        c.width = w; c.height = h;
        try { c.getContext('2d', { colorSpace: 'srgb' }).drawImage(currentImgElement, x, y, w, h, 0, 0, w, h); }
        catch (e) { c.getContext('2d').drawImage(currentImgElement, x, y, w, h, 0, 0, w, h); }
        return c;
    }

    // 矩形+テキストから新規SVG<text>レイヤーを生成しキャンバスへ追加(convertImageToTextと同じ属性体系)
    function createTextLayerEl(rect, text, color, isVertical) {
        const svg = canvasContainer && canvasContainer.querySelector('svg');
        if (!svg) return null;
        const x = rect.x, y = rect.y, w = rect.width, h = rect.height;
        const col = color || { r: 0, g: 0, b: 0 };
        const svgNS = "http://www.w3.org/2000/svg";
        const textEl = document.createElementNS(svgNS, "text");
        textEl.textContent = text || '';
        textEl.setAttribute('fill', `rgb(${col.r || 0}, ${col.g || 0}, ${col.b || 0})`);
        textEl.setAttribute('font-weight', 'bold');
        textEl.setAttribute('font-family', 'sans-serif');
        let fontSize;
        if (isVertical) {
            fontSize = Math.max(8, w * 0.8);
            textEl.setAttribute('writing-mode', 'vertical-rl');
            textEl.setAttribute('x', x + w / 2);
            textEl.setAttribute('y', y + h / 2);
            textEl.setAttribute('text-anchor', 'middle');
        } else {
            fontSize = Math.max(8, h * 0.8);
            textEl.setAttribute('x', x);
            textEl.setAttribute('y', y + h / 2);
            textEl.setAttribute('text-anchor', 'start');
        }
        textEl.setAttribute('font-size', fontSize);
        textEl.setAttribute('dominant-baseline', 'central');
        textEl.setAttribute('class', 'ist-layer-object ist-text-layer');
        textEl.dataset.ocrText = text || '';
        textEl.dataset.baseFontSize = fontSize;
        textEl.dataset.boxWidth = w;
        textEl.dataset.boxHeight = h;
        textEl.dataset.ocrVertical = isVertical ? 'true' : 'false';
        textEl.dataset.ocrColor = JSON.stringify(col);
        // 新規テキスト(元画像レイヤー無し)なので originalHref は付けない=「画像に戻す」非対象。
        svg.appendChild(textEl);
        // テキスト有り時のみボックス幅にフォントを合わせる(convertImageToTextと同様)。
        if ((text || '').length && typeof InlineSvgEditor !== 'undefined' && InlineSvgEditor.adjustTextFontSize) {
            try { InlineSvgEditor.adjustTextFontSize(textEl); } catch (e) {}
        }
        return textEl;
    }

    // 確定矩形 → 領域OCR → 新規テキストレイヤー追加(OCR失敗/空時は空テキストを置き即編集)
    async function convertMarqueeToText(rect) {
        let textEl = null;
        showLoading(true, '範囲を解析中...');
        try {
            const regionCanvas = cropRegionCanvas(rect);
            let ocrArr = null;
            if (regionCanvas && typeof MajinSplitAdapter !== 'undefined' && MajinSplitAdapter.runTextExtraction) {
                try {
                    ocrArr = await Promise.race([
                        MajinSplitAdapter.runTextExtraction(regionCanvas, function () {}),
                        new Promise(function (res) { setTimeout(function () { res(null); }, 20000); }) // タイムアウト保険
                    ]);
                } catch (e) { console.warn('[marquee] OCR失敗:', e); ocrArr = null; }
            }
            // OCR結果から テキスト/色/縦書き を決定(全box結合)
            let text = '', color = { r: 0, g: 0, b: 0 }, isVertical = (rect.height > rect.width * 1.6);
            if (Array.isArray(ocrArr) && ocrArr.length) {
                const parts = ocrArr.map(function (o) { return (o && (o.text || o.raw_text)) || ''; }).filter(Boolean);
                text = parts.join('');
                const cf = ocrArr.find(function (o) { return o && o.color; });
                if (cf && cf.color) color = cf.color;
                const vf = ocrArr.find(function (o) { return o && o.attributes && typeof o.attributes.is_vertical === 'boolean'; });
                if (vf) isVertical = !!vf.attributes.is_vertical;
            }
            try { InlineSvgEditor.clearMarquee(); } catch (e) {}
            textEl = createTextLayerEl(rect, text, color, isVertical);
            if (textEl) {
                try { InlineSvgEditor.select(textEl); } catch (e) {}
                saveHistory();
                if (!text) {
                    showStatus('テキストが認識できませんでした。手入力してください', 'info', 4000);
                    try { InlineSvgEditor.startTextEditing(textEl); } catch (e) {}
                } else {
                    showStatus('テキストに変換しました', 'success');
                }
            }
        } catch (e) {
            console.error('[marquee] convertMarqueeToText error:', e);
            try { InlineSvgEditor.clearMarquee(); } catch (e2) {}
            showStatus('範囲のテキスト化に失敗しました', 'error');
        } finally {
            showLoading(false);
            // marqueeMode は維持(連続で範囲指定可能)。ボタン再押下で解除。
        }
    }

    // --- 画像に戻すロジック ---
    function revertTextToImage(textEl) {
        if (!textEl.dataset.originalHref) return;

        const svgNS = "http://www.w3.org/2000/svg";
        const imageEl = document.createElementNS(svgNS, "image");

        const href = textEl.dataset.originalHref;
        const x = textEl.dataset.originalX;
        const y = textEl.dataset.originalY;
        const w = textEl.dataset.originalW;
        const h = textEl.dataset.originalH;

        imageEl.setAttributeNS("http://www.w3.org/1999/xlink", "href", href);
        imageEl.setAttribute("x", x);
        imageEl.setAttribute("y", y);
        imageEl.setAttribute("width", w);
        imageEl.setAttribute("height", h);
        imageEl.setAttribute("class", "ist-layer-object");
        imageEl.style.cursor = "move";

        imageEl.dataset.ocrText = textEl.dataset.ocrText;
        if(textEl.dataset.ocrColor) imageEl.dataset.ocrColor = textEl.dataset.ocrColor;
        if(textEl.dataset.ocrRaw) imageEl.dataset.ocrRaw = textEl.dataset.ocrRaw;
        if(textEl.dataset.ocrVertical) imageEl.dataset.ocrVertical = textEl.dataset.ocrVertical;
        if(textEl.dataset.ocrId) imageEl.dataset.ocrId = textEl.dataset.ocrId;

        const currentFontSize = textEl.getAttribute('font-size');
        if (currentFontSize) {
            imageEl.dataset.lastFontSize = currentFontSize;
        }

        textEl.parentNode.replaceChild(imageEl, textEl);
    }

    // --- 一括テキスト変換 ---
    function convertAllTextLayers() {
        const svg = canvasContainer.querySelector('svg');
        if (!svg) return;
        
        const targets = Array.from(svg.querySelectorAll('image[data-ocr-text]'));
        if (targets.length === 0) {
            showStatus("変換可能なテキスト領域がありません", "info");
            return;
        }
        
        //if (!confirm(`${targets.length}箇所の領域をテキストに変換しますか？`)) return;
        
        targets.forEach(img => {
            convertImageToText(img);
        });

        saveHistory();
        showStatus('テキストに変換しました', "success");
    }

    // --- 一括画像変換(テキスト化したレイヤーを元の画像に一括で戻す) [新規H] ---
    function revertAllTextLayers() {
        const svg = canvasContainer.querySelector('svg');
        if (!svg) return;
        const targets = Array.from(svg.querySelectorAll('text[data-original-href]'));
        if (targets.length === 0) {
            showStatus("画像に戻せるテキストがありません", "info");
            return;
        }
        targets.forEach(t => revertTextToImage(t));
        saveHistory();
        showStatus('画像に戻しました', "success");
    }

    // --- 一括テキスト変換(全ページ・軽量) [新規O] ---
    //   エディタを開かず・OCR/再分解もせず、slideData の splitImage.image.layers のうち
    //   分解済み(ocrData.text あり)の画像レイヤーを type:'text' レイヤーへ直接書き換える純データ処理。
    async function convertAllPagesTextLite() {
        // ★F2対策: 現在開いているページにテキスト変換編集(svg内<text>)があれば、先に slideData へ確定して未保存編集の喪失を防ぐ。
        try {
            const __openSvg = canvasContainer && canvasContainer.querySelector('svg');
            if (__openSvg && __openSvg.querySelector('text')) {
                await reflectToSlide(false, false, true);
            }
        } catch (e) { console.warn('[ist] lite: 開いているページの事前確定に失敗', e); }
        const sd = (typeof SlideEditor !== 'undefined') ? SlideEditor.getSlideData() : null;
        if (!sd || !Array.isArray(sd)) { showStatus('スライドデータが取得できません', 'error'); return; }
        buildNavList();
        const list = navList.slice();
        if (!list.length) { showStatus('対象の画像がありません', 'info'); return; }
        const toHex = (c) => {
            const cc = c || {};
            const f = (n) => Math.max(0, Math.min(255, parseInt(n, 10) || 0)).toString(16).padStart(2, '0');
            return '#' + f(cc.r) + f(cc.g) + f(cc.b);
        };
        let convertedCount = 0, pageCount = 0;
        list.forEach(t => {
            const v = sd[t.slideIndex] && sd[t.slideIndex][t.key];
            if (!v || typeof v !== 'object' || !Array.isArray(v.layers)) return;
            let pageConverted = 0;
            v.layers = v.layers.map(l => {
                if (l && l.type !== 'text' && l.ocrData && l.ocrData.text) {
                    const w = l.w || l.width || 0, h = l.h || l.height || 0;
                    const isV = !!(l.ocrData.attributes && l.ocrData.attributes.is_vertical);
                    pageConverted++;
                    // ★[表示切替] 元画像(dataBase64/key/ocrData)を保持したまま type:'text' に切替える。
                    //   これで「一括画像変換」が無処理(lite)で type:'image' に戻せる=表示の切替だけになる。
                    return Object.assign({}, l, {
                        type: 'text',
                        text: l.ocrData.text,
                        x: l.x, y: l.y, width: w, height: h,
                        color: toHex(l.ocrData.color),
                        fontSize: (isV ? w : h) * 0.8,
                        fontWeight: 'bold',
                        fontFamily: 'sans-serif',
                        isVertical: isV
                        // key/dataBase64/ocrData は l から引き継ぎ(revert用に温存)
                    });
                }
                return l;
            });
            if (pageConverted > 0) {
                if (t.key === 'image') sd[t.slideIndex].type = 'splitImage';
                convertedCount += pageConverted;
                pageCount++;
                // ★[修正] 古い画像レイヤーが残る sessionCache を破棄→次に開く/ナビゲートした時に
                //   変換後の slideData(テキストレイヤー)から再描画させる(open のキャッシュ無し経路)。
                try { delete sessionCache[`${t.slideIndex}_${t.key}`]; } catch (e) {}
            }
        });
        // textarea とエディタUIを1回だけ更新
        try {
            const ta = document.getElementById('slideDataInput');
            if (ta) ta.value = JSON.stringify(sd, null, 2);
            if (typeof SlideEditor !== 'undefined' && SlideEditor.isEditorActive()) SlideEditor.renderEditorUI();
        } catch (e) { console.warn('[ist] convertAllPagesTextLite: UI更新失敗', e); }
        if (convertedCount > 0) {
            showStatus('テキストに変換しました', 'success');
        } else {
            showStatus('テキストに変換できる領域がありませんでした', 'info');
        }
        // ★[要望] モーダルは閉じない(従来は close() で強制的に閉じていた)。
        //   現在開いているページのキャンバスにもテキスト変換を反映し、canvasとslideDataを一致させる。
        //   こうしないと、ページ移動時の saveCurrentToCache が「元画像のcanvas」で現在ページのテキスト化を上書きする退行が起きる。
        try {
            const __curSvg = canvasContainer && canvasContainer.querySelector('svg');
            if (__curSvg) {
                const __tg = Array.from(__curSvg.querySelectorAll('image[data-ocr-text]'));
                if (__tg.length) { __tg.forEach(img => convertImageToText(img)); saveHistory(); }
            }
        } catch (e) { console.warn('[ist] lite: 現在ページのキャンバス反映に失敗', e); }
    }

    // ★[表示切替] 全ページ「一括画像変換」の軽量版: text→image を無処理(再分解/OCRなし)で戻すだけ。
    //   convertAllPagesTextLite が温存した元画像(dataBase64)を使い type:'image' に切替えるのみ=表示の切替。
    async function convertAllPagesImageLite() {
        try {
            const __openSvg = canvasContainer && canvasContainer.querySelector('svg');
            if (__openSvg && __openSvg.querySelector('text')) {
                await reflectToSlide(false, false, true); // 開いているページの未保存編集を先に確定
            }
        } catch (e) { console.warn('[ist] imgLite: 開いているページの事前確定に失敗', e); }
        const sd = (typeof SlideEditor !== 'undefined') ? SlideEditor.getSlideData() : null;
        if (!sd || !Array.isArray(sd)) { showStatus('スライドデータが取得できません', 'error'); return; }
        buildNavList();
        const list = navList.slice();
        if (!list.length) { showStatus('対象の画像がありません', 'info'); return; }
        let revertedCount = 0, pageCount = 0;
        list.forEach(t => {
            const v = sd[t.slideIndex] && sd[t.slideIndex][t.key];
            if (!v || typeof v !== 'object' || !Array.isArray(v.layers)) return;
            let pageReverted = 0;
            v.layers = v.layers.map(l => {
                // 元画像(dataBase64)を保持している text レイヤーのみ image に戻す(無処理)。
                if (l && l.type === 'text' && l.dataBase64) {
                    pageReverted++;
                    return Object.assign({}, l, { type: 'image' });
                }
                return l;
            });
            if (pageReverted > 0) {
                revertedCount += pageReverted;
                pageCount++;
                try { delete sessionCache[`${t.slideIndex}_${t.key}`]; } catch (e) {}
            }
        });
        try {
            const ta = document.getElementById('slideDataInput');
            if (ta) ta.value = JSON.stringify(sd, null, 2);
            if (typeof SlideEditor !== 'undefined' && SlideEditor.isEditorActive()) SlideEditor.renderEditorUI();
        } catch (e) { console.warn('[ist] convertAllPagesImageLite: UI更新失敗', e); }
        if (revertedCount > 0) {
            showStatus('画像に戻しました', 'success');
        } else {
            showStatus('画像に戻せる領域がありませんでした（元画像が保持されたテキストのみ対象）', 'info');
        }
        // 現在開いているページのキャンバスも text→image に反映(ナビ退行防止)
        try {
            const __curSvg = canvasContainer && canvasContainer.querySelector('svg');
            if (__curSvg) {
                const __tg = Array.from(__curSvg.querySelectorAll('text[data-original-href]'));
                if (__tg.length) { __tg.forEach(tx => revertTextToImage(tx)); saveHistory(); }
            }
        } catch (e) { console.warn('[ist] imgLite: 現在ページのキャンバス反映に失敗', e); }
    }

    // --- OCR実行 ---
    async function runOCRProcess() {
        if (!currentImgElement) return;
        showLoading(true, "テキストエリアを解析中...");
        try {
            const ocrResultJson = await MajinSplitAdapter.runTextExtraction(currentImgElement, (msg) => {
                if(loadingText) loadingText.textContent = msg;
            });
            console.log("OCR Result:", ocrResultJson);
            currentOcrData = ocrResultJson; 
            showStatus('テキストを認識しました', 'success');
            await runProcess(); 
        } catch (e) {
            console.error(e);
            alert("テキスト認識に失敗しました: " + e.message);
        } finally {
            showLoading(false);
        }
    }

    // --- 画像分解実行 ---
    async function runProcess() {
        if (!currentImgElement) return;
        showLoading(true, "画像を解析中...");
        const threshold = 15;
        try {
            const result = await MajinSplitAdapter.runImageSplit(
                currentImgElement,
                currentOcrData,
                { transparencyThreshold: threshold },
                null
            );
            lastResultData = result; 
            renderResult(result);
            saveHistory();
        } catch (e) {
            console.error(e);
            alert("処理に失敗しました: " + e.message);
        } finally {
            showLoading(false);
        }
    }

    // --- 結果描画 (修正版: テキスト前面 & 小さいテキスト最前面) ---
    function renderResult(data) {
        canvasContainer.innerHTML = '';
        const width = data.originalWidth;
        const height = data.originalHeight;
        
        const svgNS = "http://www.w3.org/2000/svg";
        const svg = document.createElementNS(svgNS, "svg");
        svg.setAttribute("width", width);
        svg.setAttribute("height", height);
        svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
        
        // ▼▼▼ [修正] スタイル調整 (高さ自動、ブロック表示) ▼▼▼
        svg.style.width = "100%";
        svg.style.height = "auto"; // アスペクト比を維持して高さ自動調整
        svg.style.display = "block"; // 下部の隙間を排除
        // svg.style.maxWidth = "100%"; // 不要なら削除可
        // svg.style.maxHeight = "100%"; // 高さ制限を外し、縦長画像でもスクロールで表示
        // ▲▲▲ [修正] ここまで ▲▲▲
        
        svg.style.boxShadow = "0 4px 6px rgba(0,0,0,0.1)";
        svg.style.background = "#fff";

        if(showBordersCheck && showBordersCheck.checked) svg.classList.add('show-borders');

        // 背景
        const bgImg = document.createElementNS(svgNS, "image");
        bgImg.setAttributeNS("http://www.w3.org/1999/xlink", "href", data.background);
        bgImg.setAttribute("x", "0"); bgImg.setAttribute("y", "0");
        bgImg.setAttribute("width", width); bgImg.setAttribute("height", height);
        bgImg.setAttribute("class", "ist-bg-layer");
        bgImg.style.pointerEvents = "none";
        svg.appendChild(bgImg);

        // レイヤー (テキストまたは画像)
        if (data.layers) {
            // ソートロジックの変更
            const sortedLayers = [...data.layers].sort((a, b) => {
                const aIsText = !!a.ocrData || a.type === 'text';
                const bIsText = !!b.ocrData || b.type === 'text';

                // 1. テキストと画像が重なる場合 -> テキストを前面(1)へ
                if (aIsText && !bIsText) return 1;
                if (!aIsText && bIsText) return -1;

                // 2. 両方ともテキストの場合 -> 面積が小さい方を前面(1)へ
                if (aIsText && bIsText) {
                    const areaA = (a.width || 0) * (a.height || 0);
                    const areaB = (b.width || 0) * (b.height || 0);
                    return areaB - areaA; 
                }

                return 0;
            });

            sortedLayers.forEach(layer => {
                if (layer.type === 'text') {
                    // テキストレイヤーの復元
                    const textEl = document.createElementNS(svgNS, "text");
                    textEl.textContent = layer.text;
                    textEl.setAttribute('x', layer.x + layer.width/2); // 中心座標
                    textEl.setAttribute('y', layer.y + layer.height/2);
                    textEl.setAttribute('fill', layer.color || '#000');
                    textEl.setAttribute('font-size', layer.fontSize);
                    textEl.setAttribute('font-weight', layer.fontWeight || 'bold');
                    textEl.setAttribute('font-family', layer.fontFamily || 'sans-serif');
                    textEl.setAttribute('text-anchor', 'middle');
                    textEl.setAttribute('dominant-baseline', 'central');
                    if(layer.isVertical) textEl.setAttribute('writing-mode', 'vertical-rl');
                    
                    textEl.setAttribute('class', 'ist-layer-object ist-text-layer svg-editor-selected');
                    
                    textEl.dataset.ocrText = layer.text;
                    textEl.dataset.boxWidth = layer.width;
                    textEl.dataset.boxHeight = layer.height;
                    
                    if (layer.ocrData && layer.ocrData.color) { textEl.dataset.ocrColor = JSON.stringify(layer.ocrData.color); } else if (layer.color) { textEl.dataset.ocrColor = JSON.stringify({r:0,g:0,b:0}); }
                    // [fix 移動先ページの相互変換] convertImageToText と同じ original*/ocr* を復元し、再描画ページでも「画像に戻す」/「テキスト化」を成立させる(元画像を持つ層のみ=新規テキストは非対象)。
                    var __origImg = layer.dataBase64 || layer.key || null;
                    if (__origImg && typeof __origImg === 'string' && __origImg.indexOf('data:image/') === 0) {
                        textEl.dataset.originalHref = __origImg;
                        textEl.dataset.originalX = layer.x;
                        textEl.dataset.originalY = layer.y;
                        textEl.dataset.originalW = layer.width;
                        textEl.dataset.originalH = layer.height;
                    }
                    if (layer.ocrData) {
                        if (layer.ocrData.text != null) textEl.dataset.ocrText = layer.ocrData.text;
                        if (layer.ocrData.raw_text != null) textEl.dataset.ocrRaw = layer.ocrData.raw_text;
                        if (layer.ocrData.id != null) textEl.dataset.ocrId = layer.ocrData.id;
                        textEl.dataset.ocrVertical = !!(layer.ocrData.attributes && layer.ocrData.attributes.is_vertical);
                    } else if (layer.isVertical != null) {
                        textEl.dataset.ocrVertical = !!layer.isVertical;
                    } 
                    
                    svg.appendChild(textEl);

                } else {
                    // 画像レイヤー
                    const lImg = document.createElementNS(svgNS, "image");
                    lImg.setAttributeNS("http://www.w3.org/1999/xlink", "href", layer.dataBase64);
                    lImg.setAttribute("x", layer.x); 
                    lImg.setAttribute("y", layer.y);
                    lImg.setAttribute("width", layer.width); 
                    lImg.setAttribute("height", layer.height);
                    lImg.setAttribute("class", "ist-layer-object");
                    lImg.style.cursor = "move";
                    
                    if (layer.ocrData) {
                        lImg.dataset.ocrText = layer.ocrData.text;
                        lImg.dataset.ocrRaw = layer.ocrData.raw_text;
                        lImg.dataset.ocrColor = JSON.stringify(layer.ocrData.color);
                        lImg.dataset.ocrVertical = layer.ocrData.attributes ? layer.ocrData.attributes.is_vertical : false;
                        lImg.dataset.ocrId = layer.ocrData.id;

                        const rect = document.createElementNS(svgNS, "rect");
                        rect.setAttribute("x", layer.x); 
                        rect.setAttribute("y", layer.y);
                        rect.setAttribute("width", layer.width); 
                        rect.setAttribute("height", layer.height);
                        rect.setAttribute("fill", "none");
                        rect.setAttribute("stroke", "rgba(255, 0, 0, 0.5)");
                        rect.setAttribute("stroke-width", "2");
                        rect.setAttribute("stroke-dasharray", "4");
                        rect.setAttribute("class", "ocr-border");
                        svg.appendChild(rect);
                    }
                    svg.appendChild(lImg);
                }
            });
        }
        canvasContainer.appendChild(svg);
        
        if (typeof InlineSvgEditor !== 'undefined') {
            InlineSvgEditor.cleanup(); 
            InlineSvgEditor.init(svg);
        }
    }

    function showLoading(show, msg) {
        loadingOverlay.style.display = show ? 'flex' : 'none';
        if(msg) loadingText.textContent = msg;
    }

    // --- 履歴管理 ---
    function saveHistory() {
        const svg = canvasContainer.querySelector('svg');
        if(!svg) return;
        
        if (typeof InlineSvgEditor !== 'undefined') InlineSvgEditor.cleanup();
        
        if (historyIndex < historyStack.length - 1) {
            historyStack.splice(historyIndex + 1);
        }
        historyStack.push(svg.outerHTML);
        if (historyStack.length > 20) historyStack.shift();
        else historyIndex++;
        
        if (typeof InlineSvgEditor !== 'undefined') InlineSvgEditor.init(svg);
        
        updateButtons();
    }
    
    function undo() {
        if (historyIndex > 0) {
            historyIndex--;
            restoreHistory();
        }
    }
    
    function redo() {
        if (historyIndex < historyStack.length - 1) {
            historyIndex++;
            restoreHistory();
        }
    }
    
    function restoreHistory() {
        canvasContainer.innerHTML = historyStack[historyIndex];
        const svg = canvasContainer.querySelector('svg');
        if (svg && typeof InlineSvgEditor !== 'undefined') {
            InlineSvgEditor.cleanup();
            InlineSvgEditor.init(svg);
        }
        updateButtons();
    }
    
    function updateButtons() {
        undoBtn.disabled = historyIndex <= 0;
        redoBtn.disabled = historyIndex >= historyStack.length - 1;
    }

    // --- スライド反映 (修正版: 右拡張5%・保存のみ対応) ---
    // ★[要望3] forceFlatten: 旧flattenCheck廃止に伴い「1枚画像化」を引数で指定 / batch: 全ページ一括処理(エディタ再構築・ナビ・閉じる をしない)
    async function reflectToSlide(isServerUpdate = true, forceFlatten = false, batch = false) {
        const svg = canvasContainer.querySelector('svg');
        if (!svg) {
            if (!isServerUpdate && !batch) close();
            return;
        }

        const msg = isServerUpdate ? "反映データを生成中..." : "データを保存中...";
        if (!batch) showLoading(true, msg);

        const handles = svg.querySelector('#inlineResizeHandles');
        if(handles) handles.style.display = 'none';
        
        let originalWidth = 0;
        let originalHeight = 0;

        if (svg.viewBox && svg.viewBox.baseVal) {
            originalWidth = svg.viewBox.baseVal.width;
            originalHeight = svg.viewBox.baseVal.height;
        }
        if (!originalWidth || !originalHeight) {
             if (currentImgElement && currentImgElement.naturalWidth) {
                originalWidth = currentImgElement.naturalWidth;
                originalHeight = currentImgElement.naturalHeight;
             } else {
                originalWidth = parseFloat(svg.getAttribute('width')) || 800;
                originalHeight = parseFloat(svg.getAttribute('height')) || 600;
             }
        }
        console.log(`ImageSplitterTool: Size set to: ${originalWidth}x${originalHeight}`);
   
        const isFlatten = forceFlatten || (flattenCheck && flattenCheck.checked);
        let slideImageValue = null;
        let newSlideType = null;


        // ★★★ 追加: 元のスライドタイプを判定するためのフラグ ★★★
        let isOriginallyFullImage = false;
        if (typeof SlideEditor !== 'undefined' && targetSlideIndex !== -1) {
             const sd = SlideEditor.getSlideData();
             if (sd && sd[targetSlideIndex]) {
                 const s = sd[targetSlideIndex];
                 // 元が fullImage か、または ヘッダーフッター抑制フラグ(PDFレイアウト由来)があるか確認
                 if (s.type === 'fullImage' || s._suppressHeaderFooter === true) {
                     isOriginallyFullImage = true;
                 }
             }
        }
        // ★★★ 追加ここまで ★★★


        function getTransformedBounds(el) {
            const transform = el.getAttribute('transform') || '';
            let tx = 0, ty = 0, sx = 1, sy = 1;
            
            const tMatch = transform.match(/translate\(\s*([^\s,)]+)(?:[\s,]+([^\s,)]+))?\s*\)/);
            if (tMatch) { 
                tx = parseFloat(tMatch[1]); 
                ty = (tMatch[2] !== undefined) ? parseFloat(tMatch[2]) : 0; 
                if(isNaN(tx)) tx = 0; if(isNaN(ty)) ty = 0;
            }
            
            const sMatch = transform.match(/scale\(\s*([^\s,)]+)(?:[\s,]+([^\s,)]+))?\s*\)/);
            if (sMatch) { 
                sx = parseFloat(sMatch[1]);
                sy = (sMatch[2] !== undefined) ? parseFloat(sMatch[2]) : sx;
                if(isNaN(sx)) sx = 1; if(isNaN(sy)) sy = 1;
            }
            
            return { tx, ty, sx, sy };
        }

        function ensureHexColor(colorStr) {
            if (!colorStr) return '#000000';
            if (colorStr.startsWith('#')) return colorStr;
            
            const rgb = colorStr.match(/\d+/g);
            if (rgb && rgb.length >= 3) {
                const r = parseInt(rgb[0]);
                const g = parseInt(rgb[1]);
                const b = parseInt(rgb[2]);
                return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
            }
            return '#000000';
        }

        try {
            if (isFlatten) {
                // (フラット化)
                console.log("ImageSplitterTool: Flattening image...");
                const canvas = document.createElement('canvas');
                canvas.width = originalWidth; canvas.height = originalHeight;
                const ctx = canvas.getContext('2d');

                // ▼▼▼ [修正] SVGをクローンし、不要なUI要素を削除してから画像化する ▼▼▼
                
                // 1. SVGのクローンを作成 (表示中のSVGには影響を与えないため)
                const cloneSvg = svg.cloneNode(true);

                // 2. 不要なUI要素（赤い枠線、ハンドル、選択枠など）をクローンから削除
                // .ocr-border: テキスト認識の赤い枠
                // #inlineResizeHandles, #resizeHandles: リサイズハンドル
                // #selectionRect: 範囲選択の青い枠
                // #resizeOutline: リサイズガイド枠
                const uiSelectors = [
                    '.ocr-border', 
                    '#inlineResizeHandles', 
                    '#resizeHandles', 
                    '#selectionRect', 
                    '#resizeOutline'
                ];
                
                uiSelectors.forEach(selector => {
                    cloneSvg.querySelectorAll(selector).forEach(el => el.remove());
                });

                // 3. 選択状態のクラス (.svg-editor-selected) を削除
                // これにより、選択中の青い点線枠などが消えます
                cloneSvg.querySelectorAll('.svg-editor-selected').forEach(el => {
                    el.classList.remove('svg-editor-selected');
                    // インラインスタイルで outline が残っている場合の対策
                    el.style.outline = 'none';
                    el.style.boxShadow = 'none';
                });

                // 4. クリーンアップしたクローンSVGをシリアライズ
                const svgData = new XMLSerializer().serializeToString(cloneSvg);
                // ▲▲▲ [修正] ここまで ▲▲▲

                const img = new Image();
                await new Promise((resolve, reject) => {
                    img.onload = resolve;
                    img.onerror = reject;
                    // 日本語などの文字化け対策で encodeURIComponent を使用
                    img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
                });
                ctx.drawImage(img, 0, 0);

                const flattenedBase64 = canvas.toDataURL('image/jpeg', 0.9);
                
                slideImageValue = {
                    data: flattenedBase64,  
                    originalData: currentBase64Image 
                };
                if (isOriginallyFullImage) {
                    newSlideType = 'fullImage'; // 元がPDFレイアウトなら fullImage に戻す
                } else {
                    newSlideType = 'integratedContent'; // それ以外は integratedContent (まじん式)
                }

            } else {
                // (分割データ)
                console.log("ImageSplitterTool: Saving as splitImage...");
                const bgImg = svg.querySelector('.ist-bg-layer');
                const bgHref = bgImg ? (bgImg.getAttribute('href') || bgImg.getAttributeNS("http://www.w3.org/1999/xlink", "href")) : null;
                
                const layers = [];
                const children = Array.from(svg.children);
                
                children.forEach(el => {
                    if (el.tagName === 'g' || el.tagName === 'defs' || el.tagName === 'foreignObject' ||
                        el.classList.contains('ist-bg-layer') || el.classList.contains('ocr-border') ||
                        el.id === 'inlineResizeHandles' || el.id === 'selectionRect') {
                        return;
                    }

                    const { tx, ty, sx, sy } = getTransformedBounds(el);

                    // --- 画像レイヤー ---
                    if (el.tagName === 'image') {
                        const baseX = parseFloat(el.getAttribute('x') || 0);
                        const baseY = parseFloat(el.getAttribute('y') || 0);
                        const baseW = parseFloat(el.getAttribute('width') || 0);
                        const baseH = parseFloat(el.getAttribute('height') || 0);

                        if (baseW <= 1 || baseH <= 1) return;

                        const finalX = tx + (baseX * sx);
                        const finalY = ty + (baseY * sy);
                        const finalW = baseW * sx;
                        const finalH = baseH * sy;

                        const href = el.getAttribute('href') || el.getAttributeNS("http://www.w3.org/1999/xlink", "href");
                        
                        layers.push({
                            type: 'image',
                            dataBase64: href,
                            key: href,
                            x: finalX,
                            y: finalY,
                            width: finalW,
                            height: finalH,
                            ocrData: el.dataset.ocrText ? { 
                                text: el.dataset.ocrText,
                                raw_text: el.dataset.ocrRaw,
                                color: JSON.parse(el.dataset.ocrColor||'{}'),
                                attributes: { is_vertical: el.dataset.ocrVertical === 'true' },
                                id: el.dataset.ocrId
                            } : null
                        });
                    } 
                    // --- テキストレイヤー ---
                    else if (el.tagName === 'text') {
                        let finalX, finalY, finalW, finalH;

                        if (el.dataset.boxWidth && el.dataset.boxHeight) {
                            const originalW = parseFloat(el.dataset.boxWidth);
                            const originalH = parseFloat(el.dataset.boxHeight);
                            
                            const rawX = parseFloat(el.getAttribute('x'));
                            const rawY = parseFloat(el.getAttribute('y'));
                            const anchor = el.getAttribute('text-anchor') || 'start'; 

                            finalW = originalW * sx;
                            finalH = originalH * sy;

                            let cx = rawX;
                            if (anchor === 'start') cx = rawX + (originalW / 2);
                            else if (anchor === 'end') cx = rawX - (originalW / 2);

                            const cy = rawY;
                            const finalCx = (cx * sx) + tx;
                            const finalCy = (cy * sy) + ty;

                            finalX = finalCx - (finalW / 2);
                            finalY = finalCy - (finalH / 2);

                        } else {
                            let bbox;
                            try { bbox = el.getBBox(); } catch(e){ return; }
                            finalX = tx + (bbox.x * sx);
                            finalY = ty + (bbox.y * sy);
                            finalW = bbox.width * sx;
                            finalH = bbox.height * sy;
                        }

                        // フォントサイズ計算
                        const MEASURE_FONT_SIZE = 100;
                        const tempCanvas = document.createElement('canvas');
                        const tempCtx = tempCanvas.getContext('2d');
                        const fontFamily = el.getAttribute('font-family') || 'sans-serif';
                        const fontWeight = el.getAttribute('font-weight') || 'normal';
                        
                        const currentFsAttribute = parseFloat(el.getAttribute('font-size')) || 20;
                        let calcFontSize = currentFsAttribute * sx; 


                        if (el.dataset.baseFontSize) {
                            // A. テキスト変換由来の場合:
                            // 初期フォントサイズ(baseFontSize) × 現在の拡大率(sx) を採用。
                            // これにより、テキスト内容を変更・削除してもフォントサイズが維持されます。
                            calcFontSize = parseFloat(el.dataset.baseFontSize) * sx;
                        } else {
                            // B. それ以外の場合 (手動追加など):
                            // 従来通り、枠の幅に合わせてフォントサイズを自動調整
                            tempCtx.font = `${fontWeight} ${MEASURE_FONT_SIZE}px "${fontFamily}"`;
                            const textMetrics = tempCtx.measureText(el.textContent || '');
                            const rawTextWidth = textMetrics.width;

                            if (rawTextWidth > 0 && finalW > 0) {
                                calcFontSize = (finalW * MEASURE_FONT_SIZE) / rawTextWidth;
                            }
                        }



                        // ボックスサイズの拡張処理 (左: 高さの1/4、右: 幅の5%)
                        const extensionLeft = finalH / 4;
                        const extensionRight = Math.max(finalW * 0.10, finalH * 1.5);

                        let adjustedX = finalX - extensionLeft;
                        let intendedRightEdge = (finalX + finalW) + extensionRight;

                        if (intendedRightEdge > originalWidth) {
                            intendedRightEdge = originalWidth;
                        }

                        let adjustedW = intendedRightEdge - adjustedX;
                        finalX = adjustedX;
                        finalW = adjustedW;

                        const rawColor = el.getAttribute('fill') || '#000000';
                        const hexColor = ensureHexColor(rawColor);

                        layers.push({
                            type: 'text',
                            text: el.textContent,
                            x: finalX,
                            y: finalY,
                            width: finalW,
                            height: finalH,
                            transform: el.getAttribute('transform'),
                            color: hexColor, 
                            fontSize: calcFontSize, 
                            fontWeight: el.getAttribute('font-weight'),
                            fontFamily: el.getAttribute('font-family'),
                            isVertical: el.getAttribute('writing-mode') === 'vertical-rl',
                            key: `text_${Date.now()}_${Math.random()}`
                        });
                    }
                });

                slideImageValue = {
                    data: currentBase64Image,
                    originalData: currentBase64Image,
                    backgroundImage: bgHref,
                    layers: layers,
                    originalWidth: originalWidth,
                    originalHeight: originalHeight,
                    ocrCache: currentOcrData,
                    ocrCacheEngine: MajinSplitAdapter.resolveMode()
                };
                newSlideType = 'splitImage';
            }

            if (typeof SlideEditor !== 'undefined') {
                const slideData = SlideEditor.getSlideData();
                if (slideData && slideData[targetSlideIndex]) {
                    const slide = slideData[targetSlideIndex];

                    if (slide.type === 'fullImage') {
                        slide._suppressHeaderFooter = true;
                    }

                    if (newSlideType) slide.type = newSlideType;
                    slide.image = slideImageValue;

                    // ★[要望3] 全ページ一括処理(batch)時は、ここで slideData へ書くだけ。
                    //   エディタ再構築・ページナビ・サーバ更新・閉じる は呼び出し元(runActionAllPages)が一括で行う。
                    if (batch) {
                        if (handles) handles.style.display = 'block';
                        return;
                    }

                    SlideEditor.exitEditorMode({ skipValidation: true });
                    document.getElementById('slideDataInput').value = JSON.stringify(slideData, null, 2);
                    SlideEditor.enterEditorMode();

                    if (targetSlideIndex > 0) {
                        for(let i=0; i<targetSlideIndex; i++) {
                            const nextBtn = document.getElementById('editorNextBtn');
                            if(nextBtn) nextBtn.click();
                        }
                    }




                    if (isServerUpdate) {
                        // ▼▼▼ [修正] 即座に閉じて、裏で更新処理を開始する ▼▼▼
                        showLoading(false); // ロード表示を消す
                        close(); // モーダルを閉じる（キャッシュ保存含む）

                        // メイン画面の更新処理をキック（awaitしない）
                        if (typeof window.updateCurrentPage === 'function') {
                            // 少し遅延させて、モーダルが閉じる描画を優先させる
                            setTimeout(() => {
                                window.updateCurrentPage(); 
                            }, 50);
                        } else {
                            alert("更新関数が見つかりません");
                        }
                        // ▲▲▲ [修正] ここまで ▲▲▲
                    } else {
                        // 保存のみで閉じる場合
                        close();
                    }




                }
            }
        } catch(e) {
            console.error(e);
            if (batch) { throw e; } // ★[要望3] 全ページ処理時は呼び出し元でまとめて握る(ページ毎のalertを出さない)
            alert("反映処理中にエラーが発生しました: " + e.message);
        } finally {
            if(handles) handles.style.display = 'block';
            if (!batch) showLoading(false); // ★[要望3] batch時は全ページ進捗オーバーレイを維持
        }
    }

    // ★★★ [修正] preLoad: ocrCacheがある場合はOCRをスキップして高速化 ★★★
    async function preLoad(base64Image, slideIndex, key) {
        const cacheKey = `${slideIndex}_${key}`;
        // 1. セッションキャッシュ（メモリ）にあれば即終了
        if (sessionCache[cacheKey]) return;

        console.log(`[PreLoad] Processing Slide ${slideIndex}...`);
        
        try {
            const img = new Image();
            await new Promise((resolve, reject) => {
                img.onload = resolve; img.onerror = reject; img.src = base64Image;
            });

            // ▼▼▼ [修正] 保存されたocrCacheがあるか確認 ▼▼▼
            // ★ NotebookLMエンジン切替: 現在のエンジンを解決(legacy/notebooklm)。キャッシュはエンジン別に扱う。
            const __mode = MajinSplitAdapter.resolveMode();
            let ocrResult = null;

            // 現在のデータを確認
            if (typeof SlideEditor !== 'undefined') {
                const currentData = SlideEditor.getSlideData();
                if (currentData && currentData[slideIndex]) {
                    const target = currentData[slideIndex][key];
                    // オブジェクトで、かつ 同一エンジンの ocrCache を持っている場合のみ再利用
                    if (target && typeof target === 'object' && target.ocrCache && (target.ocrCacheEngine || 'legacy') === __mode) {
                        console.log(`[PreLoad] Slide ${slideIndex}: 保存されたOCRキャッシュを使用して高速化します`);
                        ocrResult = target.ocrCache;
                    }
                }
            }

            // キャッシュがなければOCR実行、あればスキップ
            if (!ocrResult) {
                // 重い処理
                ocrResult = await MajinSplitAdapter.runTextExtraction(img, null, __mode);
            }
            // ▲▲▲ [修正] ここまで ▲▲▲

            // 分解処理 (OCR結果を使ってレイヤーを生成) - これは比較的軽量なので都度実行
            const splitResult = await MajinSplitAdapter.runImageSplit(img, ocrResult, { transparencyThreshold: 15 }, null, __mode);

            sessionCache[cacheKey] = {
                historyStack: [], 
                historyIndex: -1,
                currentOcrData: ocrResult,
                currentBase64Image: base64Image,
                isFlatten: false,
                preloadedData: {
                    originalWidth: splitResult.originalWidth,
                    originalHeight: splitResult.originalHeight,
                    background: splitResult.background,
                    layers: splitResult.layers.map(l => ({
                        type: 'image',
                        dataBase64: l.dataBase64,
                        key: l.dataBase64, 
                        x: l.x, y: l.y, width: l.width, height: l.height,
                        ocrData: l.ocrData ? {
                            text: l.ocrData.text,
                            raw_text: l.ocrData.raw_text,
                            color: l.ocrData.color,
                            attributes: l.ocrData.attributes,
                            id: l.ocrData.id
                        } : null
                    }))
                }
            };

            // ここで再度データを取得して書き込み（念のため）
            const currentData = SlideEditor.getSlideData();
            if (currentData && currentData[slideIndex]) {
                const target = currentData[slideIndex][key];
                if (typeof target !== 'object' || target === null) {
                    currentData[slideIndex][key] = { data: base64Image, originalData: base64Image };
                }
            
                currentData[slideIndex][key]._cached = true;

                // OCR結果を保存（未保存、または別エンジンのキャッシュだった場合は現エンジンの結果で更新）
                if (currentData[slideIndex][key] && (!currentData[slideIndex][key].ocrCache || (currentData[slideIndex][key].ocrCacheEngine || 'legacy') !== __mode)) {
                     currentData[slideIndex][key].ocrCache = ocrResult;
                     currentData[slideIndex][key].ocrCacheEngine = __mode;
                }

                const textarea = document.getElementById('slideDataInput');
                if (textarea && (!SlideEditor || !SlideEditor.isEditorActive())) {
                    textarea.value = JSON.stringify(currentData, null, 2);
                }
                
                if (SlideEditor.isEditorActive()) {
                    if (SlideEditor.getCurrentIndex() === slideIndex) {
                        SlideEditor.renderEditorUI(); 
                    }
                }
            }
            console.log(`[PreLoad] Slide ${slideIndex} cached.`);

        } catch (e) {
            console.warn(`[PreLoad] Failed:`, e);
        }
    }

    function getCache(slideIndex, key) {
        return sessionCache[`${slideIndex}_${key}`];
    }
    
    function hasCache(slideIndex, key) {
        return !!sessionCache[`${slideIndex}_${key}`];
    }

    return { 
        init: init,
        open: open,
        preLoad: preLoad,
        getCache: getCache,
        hasCache: hasCache
    };
})();


/**
 * ★ バックグラウンドプリロード (通知付き・完了後自動保存)
 */
async function startBackgroundPreloading() {
    if (typeof SlideEditor === 'undefined' || typeof ImageSplitterTool === 'undefined') return;

    const slideData = SlideEditor.getSlideData();
    if (!slideData || slideData.length === 0) return;

    console.log("--- [Background] Start Pre-loading ---");
    const statusEl = document.getElementById('validationStatus'); 
    const statusTextEl = document.getElementById('validationDetails'); 

    const imageKeys = ['image', 'backgroundImage', 'foregroundImage'];
    let hasUpdates = false; // ★追加: 更新があったかどうかのフラグ

    for (let i = 0; i < slideData.length; i++) {
        for (const key of imageKeys) {
            const val = slideData[i][key];
            if (!val) continue;

            let targetBase64 = null;
            if (typeof val === 'string' && val.startsWith('data:image/')) {
                targetBase64 = val;
            } else if (val.data && typeof val.data === 'string' && val.data.startsWith('data:image/')) {
                // ★SVG生成バグ修正(2026-06-26): 生成由来(svg_code/chart/animation)は画像分解の対象外にする。
                //   これらは info でタグ付けされ type は未定義のため、従来は !val.type で素通りし自動OCR/過分割していた。
                const isGenerated = ['svg_code', 'chart', 'animation'].includes(val.info);
                if (!isGenerated && (val.type === 'notebooklm' || !val.type)) targetBase64 = val.data;
            } else if (val.originalData && typeof val.originalData === 'string' && val.originalData.startsWith('data:image/')) {
                // ★SVG生成バグ修正(2026-06-26): originalData も data:image/ ラスターに限定（生SVG等を preLoad に渡さない）。
                targetBase64 = val.originalData;
            }

            if (targetBase64) {
                // キャッシュ済みならスキップ
                if (ImageSplitterTool.hasCache(i, key)) continue;

                if (statusTextEl && statusEl.style.display !== 'none') {
                    statusTextEl.style.display = 'block';
                    statusTextEl.textContent = `バックグラウンド処理中: ページ ${i+1} を解析しています...`;
                    statusTextEl.className = 'validation-details validation-warning';
                }

                await ImageSplitterTool.preLoad(targetBase64, i, key);
                hasUpdates = true; // ★追加: 更新フラグを立てる
                await new Promise(r => setTimeout(r, 100)); 
            }
        }
    }
    
    if (statusTextEl) {
        // ▼▼▼ [修正] 更新があった場合、サーバーに保存する処理を追加 ▼▼▼
        if (hasUpdates && typeof lastGeneratedSlideUrl !== 'undefined' && lastGeneratedSlideUrl) {
            statusTextEl.textContent = '解析完了。OCR結果を保存しています...';
            
            const match = lastGeneratedSlideUrl.match(/\/d\/([a-zA-Z0-9_-]+)|\?id=([a-zA-Z0-9_-]+)/);
            const presentationId = match ? (match[1] || match[2]) : null;

            if (presentationId) {
                const currentData = SlideEditor.getSlideData(); // OCR結果が含まれた最新データを取得
                const currentSettings = getCurrentSettings();

                google.script.run
                    .withSuccessHandler(() => {
                        console.log("[Background] OCR cache saved to server.");
                        statusTextEl.textContent = '全ページの画像解析と保存が完了しました。';
                        statusTextEl.className = 'validation-details validation-success';
                        setTimeout(() => { statusTextEl.style.display = 'none'; }, 3000);
                    })
                    .withFailureHandler((e) => {
                        console.warn("[Background] Save failed:", e);
                        statusTextEl.textContent = '解析完了（保存に失敗しました）';
                        setTimeout(() => { statusTextEl.style.display = 'none'; }, 3000);
                    })
                    .saveGenerationState(presentationId, currentData, currentSettings);
                
                return; // 保存処理の完了を待つため、ここで関数を抜ける
            }
        }
        // ▲▲▲ [修正] ここまで ▲▲▲

        statusTextEl.textContent = '全ページの画像解析が完了しました（キャッシュ済み）';
        statusTextEl.className = 'validation-details validation-success'; 
        setTimeout(() => { statusTextEl.style.display = 'none'; }, 3000);
    }
    console.log("--- [Background] Pre-loading Finished ---");
}


/**
 * ★ 一括画像分解モジュール (PDF全ページ / JSON全画像 を1回の操作でまじん式の画像分解スライドに)
 *   - 既存の単一画像分解（MajinSplitAdapter + ImageSplitterTool.preLoad）と engine/品質を共有。
 *   - 分解結果は既存の splitImage フォーマットでslideDataに書き戻す（reflectToSlideの分割保持と同形）。
 *   - 進捗バー＋キャンセル付き。メモリは preLoad のキャッシュに委譲。
 */
const BatchImageSplit = (function () {
    let _cancel = false;
    let _overlay = null;

    function loadImage(src) {
        return new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = src; });
    }

    // --- 進捗オーバーレイ（動的生成） ---
    function ensureOverlay() {
        if (_overlay) return _overlay;
        const o = document.createElement('div');
        o.id = 'batchSplitOverlay';
        o.style.cssText = 'position:fixed;inset:0;z-index:10050;display:none;align-items:center;justify-content:center;background:rgba(17,24,39,0.55);';
        o.innerHTML =
            '<div style="background:#fff;border-radius:12px;padding:1.5rem 1.75rem;width:min(420px,90vw);box-shadow:0 12px 40px rgba(0,0,0,0.3);text-align:center;">' +
            '<div id="batchSplitTitle" style="font-weight:700;margin-bottom:0.75rem;">画像分解の準備中…</div>' +
            '<div style="height:10px;border-radius:6px;background:#E5E7EB;overflow:hidden;margin-bottom:0.5rem;">' +
            '<div id="batchSplitBar" style="height:100%;width:0%;background:var(--primary-color,#4285F4);transition:width 0.2s;"></div></div>' +
            '<div id="batchSplitSub" style="font-size:0.8rem;color:#6B7280;margin-bottom:1rem;">しばらくお待ちください</div>' +
            '<button id="batchSplitCancel" type="button" class="btn btn-secondary" style="background:#6B7280;">キャンセル</button>' +
            '</div>';
        document.body.appendChild(o);
        o.querySelector('#batchSplitCancel').addEventListener('click', function () { _cancel = true; setSub('キャンセル中…処理中のページが終わり次第停止します'); });
        _overlay = o;
        return o;
    }
    function showProgress(title) { const o = ensureOverlay(); o.style.display = 'flex'; setTitle(title || '画像分解中…'); setBar(0); }
    function hideProgress() { if (_overlay) _overlay.style.display = 'none'; }
    function setTitle(t) { const e = _overlay && _overlay.querySelector('#batchSplitTitle'); if (e) e.textContent = t; }
    function setSub(t) { const e = _overlay && _overlay.querySelector('#batchSplitSub'); if (e) e.textContent = t; }
    function setBar(frac) { const e = _overlay && _overlay.querySelector('#batchSplitBar'); if (e) e.style.width = Math.max(0, Math.min(100, Math.round(frac * 100))) + '%'; }

    // --- slideDataの画像値からbase64を取り出す（startBackgroundPreloadingと同基準） ---
    function extractBase64(val) {
        if (!val) return null;
        if (typeof val === 'string' && val.startsWith('data:image/')) return val;
        if (val.data && typeof val.data === 'string' && val.data.startsWith('data:image/')) {
            if (['svg_code', 'chart', 'animation'].includes(val.info)) return null;
            if (val.type === 'notebooklm' || !val.type) return val.data;
            return null;
        }
        if (val.originalData && typeof val.originalData === 'string' && val.originalData.startsWith('data:image/')) return val.originalData;
        return null;
    }
    function alreadySplit(val) { return !!(val && typeof val === 'object' && Array.isArray(val.layers)); }

    // --- 1画像を分解しsplitImage値をslideDataに書き戻す（preLoad再利用＝engine/品質を共有） ---
    async function decomposeOne(slideData, i, key, base64) {
        await ImageSplitterTool.preLoad(base64, i, key);
        const cache = ImageSplitterTool.getCache(i, key);
        if (!cache || !cache.preloadedData) return false;
        const pre = cache.preloadedData;
        const existing = (slideData[i][key] && typeof slideData[i][key] === 'object') ? slideData[i][key] : {};
        slideData[i][key] = Object.assign({}, existing, {
            data: existing.data || base64,
            originalData: existing.originalData || base64,
            backgroundImage: pre.background,
            layers: pre.layers,
            originalWidth: pre.originalWidth,
            originalHeight: pre.originalHeight,
            ocrCache: cache.currentOcrData,
            ocrCacheEngine: (typeof MajinSplitAdapter !== 'undefined') ? MajinSplitAdapter.resolveMode() : 'legacy'
        });
        if (key === 'image') slideData[i].type = 'splitImage';
        return true;
    }

    function reflectSlideData(slideData) {
        const ta = document.getElementById('slideDataInput');
        if (ta) ta.value = JSON.stringify(slideData, null, 2);
        try {
            if (typeof SlideEditor !== 'undefined') {
                if (SlideEditor.isEditorActive()) SlideEditor.renderEditorUI();
                else SlideEditor.enterEditorMode();
            }
        } catch (e) { console.warn('[BatchSplit] reflect failed', e); }
    }

    // --- (1) JSON経由: 現在の全スライドの画像を一括分解 ---
    async function decomposeAllCurrentSlides() {
        if (typeof SlideEditor === 'undefined' || typeof ImageSplitterTool === 'undefined') { alert('エディタが初期化されていません。'); return; }
        const slideData = SlideEditor.getSlideData();
        if (!slideData || !slideData.length) { alert('先にスライド構成データを読み込んでください。'); return; }
        const imageKeys = ['image', 'backgroundImage', 'foregroundImage'];
        const targets = [];
        for (let i = 0; i < slideData.length; i++) {
            for (const key of imageKeys) {
                if (alreadySplit(slideData[i][key])) continue;
                const b = extractBase64(slideData[i][key]);
                if (b) targets.push({ i, key, b });
            }
        }
        if (!targets.length) { alert('分解できる画像がスライド内に見つかりませんでした。'); return; }
        _cancel = false; showProgress('全ページを一括分解しています…');
        let done = 0;
        for (let t = 0; t < targets.length; t++) {
            if (_cancel) break;
            setSub('ページ ' + (targets[t].i + 1) + ' を解析中… (' + (t + 1) + '/' + targets.length + ')');
            setBar(t / targets.length);
            try { if (await decomposeOne(slideData, targets[t].i, targets[t].key, targets[t].b)) done++; }
            catch (e) { console.warn('[BatchSplit] decompose failed', e); }
            await new Promise(r => setTimeout(r, 0));
        }
        setBar(1); hideProgress();
        reflectSlideData(slideData);
        alert(done + '件の画像を分解しました' + (_cancel ? '（途中でキャンセル）' : '') + '。各スライドはクリックで微調整できます。');
    }

    // --- PDFページ→画像 ---
    async function renderPageRaw(pdf, pageNum) {
        const page = await pdf.getPage(pageNum);
        // 解像度は最大辺 ~2000px を目標に。元PDFを不要に拡大しないことで描画とOCRを高速化し、
        // 高解像PDFは上限で抑える（分解には2000px前後あれば十分）。
        const base = page.getViewport({ scale: 1 });
        const scale = Math.min(2, Math.max(1, 2000 / Math.max(base.width, base.height)));
        const vp = page.getViewport({ scale: scale });
        const c = document.createElement('canvas'); c.width = vp.width; c.height = vp.height;
        await page.render({ canvasContext: c.getContext('2d', { willReadFrequently: true }), viewport: vp }).promise;
        return c.toDataURL('image/jpeg', 0.92);
    }
    async function renderPageMajin(pdf, pageNum) {
        const page = await pdf.getPage(pageNum);
        if (typeof IntegratedLayoutEngine === 'undefined' || !IntegratedLayoutEngine.processSinglePage) return renderPageRaw(pdf, pageNum);
        let r;
        try { r = await IntegratedLayoutEngine.processSinglePage(page); } catch (e) { console.warn('[BatchSplit] majin layout failed, fallback', e); return renderPageRaw(pdf, pageNum); }
        if (!r || !r.bgData) return renderPageRaw(pdf, pageNum);
        const bg = await loadImage(r.bgData);
        const c = document.createElement('canvas'); c.width = bg.naturalWidth || bg.width; c.height = bg.naturalHeight || bg.height;
        const ctx = c.getContext('2d'); ctx.drawImage(bg, 0, 0, c.width, c.height);
        if (r.mergedData) { try { const fg = await loadImage(r.mergedData); ctx.drawImage(fg, 0, 0, c.width, c.height); } catch (e) { } }
        return c.toDataURL('image/jpeg', 0.92);
    }

    // --- 範囲＋下地トグル モーダル ---
    function askRange(total, defaultTitle) {
        return new Promise(resolve => {
            const modal = document.getElementById('batchPdfRangeModal');
            const startSel = document.getElementById('batchRangeStart');
            const endSel = document.getElementById('batchRangeEnd');
            const titleInput = document.getElementById('batchRangeTitle'); // ★[要望2] スライド名入力欄
            const okBtn = document.getElementById('batchRangeOk');
            const cancelBtn = document.getElementById('batchRangeCancel');
            const totalEl = document.getElementById('batchRangeTotal');
            if (!modal) { resolve({ start: 1, end: total, base: 'raw', title: defaultTitle || '' }); return; }
            startSel.innerHTML = ''; endSel.innerHTML = '';
            for (let i = 1; i <= total; i++) {
                const o1 = document.createElement('option'); o1.value = i; o1.textContent = i; startSel.appendChild(o1);
                const o2 = document.createElement('option'); o2.value = i; o2.textContent = i; endSel.appendChild(o2);
            }
            startSel.value = 1; endSel.value = total;
            if (titleInput) titleInput.value = defaultTitle || ''; // ★[要望2] デフォルト名(ファイル名+日付)をセット
            if (totalEl) totalEl.textContent = total;
            modal.style.display = 'flex';
            function cleanup() { modal.style.display = 'none'; okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); }
            function onOk() {
                let s = parseInt(startSel.value, 10) || 1, e = parseInt(endSel.value, 10) || total;
                if (s > e) { const t = s; s = e; e = t; }
                const baseEl = document.querySelector('input[name="batchPdfBase"]:checked');
                const base = baseEl ? baseEl.value : 'raw';
                const title = (titleInput && titleInput.value.trim()) ? titleInput.value.trim() : (defaultTitle || ''); // ★[要望2]
                cleanup(); resolve({ start: s, end: e, base, title });
            }
            function onCancel() { cleanup(); resolve(null); }
            okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel);
        });
    }

    // --- (2) PDF経路: 貼付け/ドロップ/選択されたPDF → 全ページ一括分解 ---
    async function handlePdfFile(file) {
        if (!file || file.type !== 'application/pdf') { alert('PDFファイルを指定してください。'); return; }
        if (typeof pdfjsLib === 'undefined') { alert('PDFライブラリが読み込まれていません。'); return; }
        let pdf;
        try { const ab = await file.arrayBuffer(); pdf = await pdfjsLib.getDocument({ data: ab }).promise; }
        catch (e) { alert('PDFの読み込みに失敗しました: ' + (e && e.message ? e.message : e)); return; }

        // ★[要望2] デフォルトのスライド名 = PDFファイル名(拡張子除去) + 日付(YYYY.MM.DD)
        const baseName = (file.name || 'PDF').replace(/\.[^.]+$/, '').trim() || 'PDF';
        const _d = new Date();
        const dateStr = _d.getFullYear() + '.' + String(_d.getMonth() + 1).padStart(2, '0') + '.' + String(_d.getDate()).padStart(2, '0');
        const defaultTitle = baseName + ' ' + dateStr;

        const choice = await askRange(pdf.numPages, defaultTitle);
        if (!choice) return;
        const { start, end, base, title } = choice;
        const deckTitle = (title && title.trim()) ? title.trim() : defaultTitle; // ★[要望2] ユーザー指定 or デフォルト
        const cnt = end - start + 1;
        _cancel = false; showProgress('PDFを画像分解スライドに変換しています…');

        // ★[要望] 進捗バーをページ数で線形に進める: 各ページを「画像化→分解」まで1枚ずつ処理し、
        //   1ページ完了ごとに setBar(処理済ページ/総ページ) で進める。
        //   (従来は 画像化0-40% → 分解40-100% の2段で、分解の1枚目が40%≒半分から始まっていた。)
        //   分解(decomposeOne→preLoad)はエディタ状態に依存しないため、エディタ読込前にローカル配列で実行できる。
        const slides = [];
        let done = 0;
        for (let i = start; i <= end; i++) {
            if (_cancel) break;
            const rel = i - start + 1;
            setSub('PDFページを画像化中… ' + rel + '/' + cnt); setBar((rel - 1) / cnt);
            let img64;
            try { img64 = base === 'majin' ? await renderPageMajin(pdf, i) : await renderPageRaw(pdf, i); }
            catch (e) { console.warn('[BatchSplit] page render failed', i, e); setBar(rel / cnt); continue; }
            const idx = slides.length;
            // ★[要望2] 1ページ目の title をデッキ名にする(createPresentation が slideData[0].title をデッキ名に採用)
            slides.push({ type: 'fullImage', title: (rel === 1 ? deckTitle : ('Page ' + i)), image: img64, _suppressHeaderFooter: true, notes: '' });
            setSub('画像分解中… ' + rel + '/' + cnt + ' ページ'); setBar((rel - 1 + 0.3) / cnt); // 画像化完了=そのページ枠の3割
            try { if (await decomposeOne(slides, idx, 'image', img64)) done++; }
            catch (e) { console.warn('[BatchSplit] decompose failed', i, e); }
            setBar(rel / cnt); // ★ 1ページ完了 = 1/総ページ ぶん進む
            await new Promise(r => setTimeout(r, 0));
        }
        if (!slides.length) { hideProgress(); alert('PDFページの画像化に失敗しました。'); return; }

        // エディタへ読み込み(分解済みデータ)
        const ta = document.getElementById('slideDataInput');
        if (ta) ta.value = JSON.stringify(slides, null, 2);
        try { if (typeof SlideEditor !== 'undefined') SlideEditor.enterEditorMode(); } catch (e) { console.warn(e); }
        const slideData = (typeof SlideEditor !== 'undefined' && SlideEditor.getSlideData()) || slides;

        setBar(1); hideProgress();
        reflectSlideData(slideData);
        // 完了アラートは出さない(ユーザー要望)。結果はエディタに反映済み。
        console.log('[BatchSplit] ' + done + 'ページの画像分解スライドを作成しました' + (_cancel ? '（途中でキャンセル）' : ''));
    }

    // --- アップロード/ドロップされたファイルを種類で振り分け: PDF→分解フロー / JSON(等のテキスト)→構成データ欄へ読込 ---
    async function loadAnyFile(f) {
        if (!f) return;
        const isPdf = f.type === 'application/pdf' || /\.pdf$/i.test(f.name);
        if (isPdf) { handlePdfFile(f); return; }
        try {
            const text = await f.text();
            const area = document.getElementById('slideDataInput');
            if (area) { area.value = text; area.dispatchEvent(new Event('input', { bubbles: true })); area.dispatchEvent(new Event('change', { bubbles: true })); }
        } catch (err) { alert('ファイルの読み込みに失敗しました: ' + (err && err.message ? err.message : err)); }
    }

    // --- 入口の配線 ---
    function init() {
        const fileInput = document.getElementById('batchPdfFileInput');
        const pdfBtn = document.getElementById('batchPdfBtn');
        const allBtn = document.getElementById('batchDecomposeAllBtn');
        const ta = document.getElementById('slideDataInput');

        if (pdfBtn && fileInput) {
            pdfBtn.addEventListener('click', function () { fileInput.value = ''; fileInput.click(); });
            fileInput.addEventListener('change', function (e) { const f = e.target.files && e.target.files[0]; if (f) handlePdfFile(f); });
        }
        if (allBtn) allBtn.addEventListener('click', function () { decomposeAllCurrentSlides(); });

        // ★ アップロードアイコン(履歴の左): ダイアログから JSON または PDF を取込
        const upBtn = document.getElementById('uploadFileBtn');
        const upInput = document.getElementById('uploadFileInput');
        if (upBtn && upInput) {
            upBtn.addEventListener('click', function () { upInput.value = ''; upInput.click(); });
            upInput.addEventListener('change', function (e) { const f = e.target.files && e.target.files[0]; if (f) loadAnyFile(f); });
        }

        // メイン貼付け欄: PDF/JSONファイルのドロップ・ペースト
        if (ta) {
            ta.addEventListener('dragover', function (e) { if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') !== -1) { e.preventDefault(); } });
            ta.addEventListener('drop', function (e) {
                const files = e.dataTransfer && e.dataTransfer.files;
                if (!files || !files.length) return;
                const f = files[0];
                if (f.type === 'application/pdf' || /\.pdf$/i.test(f.name) || f.type === 'application/json' || /\.(json|txt)$/i.test(f.name)) {
                    e.preventDefault(); e.stopPropagation(); loadAnyFile(f);
                }
            });
            // メイン貼付け欄: PDFのペースト
            ta.addEventListener('paste', function (e) {
                const items = (e.clipboardData || window.clipboardData) ? (e.clipboardData || window.clipboardData).items : null;
                if (!items) return;
                for (const it of items) { if (it.kind === 'file' && it.type === 'application/pdf') { e.preventDefault(); const f = it.getAsFile(); if (f) handlePdfFile(f); return; } }
            });
        }
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();

    // ★[要望1] NotebookLM自動分解フローから再利用するため内部部品も公開
    return { handlePdfFile, decomposeAllCurrentSlides, init, decomposeOne, renderPageRaw, renderPageMajin,
             // ★ NotebookLM自動分解からブロッキング進捗オーバーレイを共用するため公開
             showProgress: showProgress, setSub: setSub, setBar: setBar, hideProgress: hideProgress,
             resetCancel: function () { _cancel = false; }, isCancelled: function () { return _cancel; } };
})();


// ========================================================================
// ★ 高速パス(分解デッキ): splitImage の分解結果を PptxExportEngine(クライアント)で
//    「レイアウト済みPPTX(テキストはネイティブ編集可能テキスト＋分離画像)」にし、
//    既存の PPTX→Googleスライド変換(サーバ generateDeckFromClientPptx → convertPptxToSlides)で
//    一括デッキ化する。GASが1要素ずつ insertImage(約450ms/枚)する従来経路を回避して大幅高速化。
//    非対応/失敗時は null を返し、呼び出し側が従来 generate() にフォールバックする(退行ゼロ)。
// ========================================================================
const PptxFastPath = (function () {
  // splitImage スライド配列 → PptxExportEngine の pages[]。1枚でも非対応なら null(=従来経路へ)。
  function buildPages(slideData) {
    if (!Array.isArray(slideData) || !slideData.length) return null;
    const pages = [];
    for (const slide of slideData) {
      if (!slide || slide.type !== 'splitImage') return null;
      const img = slide.image;
      if (!img || typeof img !== 'object' || !Array.isArray(img.layers)) return null;
      const iw = img.originalWidth, ih = img.originalHeight;
      if (!iw || !ih) return null; // ゼロ除算回避→従来経路
      const bg = img.backgroundImage || img.originalData || img.data;
      if (typeof bg !== 'string' || bg.indexOf('data:image/') !== 0) return null;
      const els = img.layers.map(function (l) {
        const x = l.x, y = l.y, w = (l.width != null ? l.width : l.w), h = (l.height != null ? l.height : l.h);
        const oc = l.ocrData || null;
        const txt = oc && (oc.text || oc.raw_text);
        if (txt && String(txt).trim()) {
          const col = (oc.color && typeof oc.color === 'object') ? oc.color : { r: 0, g: 0, b: 0 };
          return { type: 'text', ocrText: String(txt), x: x, y: y, width: w, height: h, avgColor: col, showOriginal: false };
        }
        return { type: 'image', dataBase64: (l.dataBase64 || l.key), x: x, y: y, width: w, height: h };
      }).filter(function (e) { return e && e.width > 0 && e.height > 0 && (e.type !== 'image' || (typeof e.dataBase64 === 'string' && e.dataBase64.indexOf('data:image/') === 0)); });
      pages.push({ originalImageWidth: iw, originalImageHeight: ih, originalImageData: bg, detectedElements: els });
    }
    return pages.length ? pages : null;
  }

  function isApplicable(slideData) {
    return !!(buildPages(slideData)
      && window.NLM && window.NLM.PptxExportEngine && window.NLM.PptxExportEngine.exportBase64
      && typeof PptxGenJS !== 'undefined'
      && typeof _getDriveUploadToken === 'function' && typeof _uploadBlobToDrive === 'function');
  }

  // 分解デッキを高速生成。成功=URL文字列、非対応/失敗=null(呼び出し側で従来経路へフォールバック)。
  async function generate(slideData, settings, presentationId, onStatus) {
    try {
      const pages = buildPages(slideData);
      if (!pages) { if (onStatus) onStatus('高速パス非対応'); return null; }
      if (!(window.NLM && window.NLM.PptxExportEngine && window.NLM.PptxExportEngine.exportBase64) || typeof PptxGenJS === 'undefined') {
        if (onStatus) onStatus('PptxExportEngine/PptxGenJS 未ロード'); return null;
      }
      if (onStatus) onStatus('レイアウト済みPPTXを生成中…');
      const b64 = await window.NLM.PptxExportEngine.exportBase64({ pages: pages, backgroundColor: { r: 255, g: 255, b: 255 }, fileName: 'majin_split_deck.pptx' });
      if (!b64) return null;
      // base64 → Blob(fetch data URL 経由=巨大文字列でも効率的)
      const MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
      const blob = await (await fetch('data:' + MIME + ';base64,' + b64)).blob();
      if (onStatus) onStatus('Driveへアップロード中…');
      // 常に Drive(resumable/multipart)経由で送る=巨大base64をgoogle.script.run(50MB上限)に載せない
      const tk = await _getDriveUploadToken();
      const fileId = await _uploadBlobToDrive(blob, 'majin_deck_' + Date.now() + '.pptx', tk.token);
      if (onStatus) onStatus('Googleスライドへ変換中…');
      // ★[fix:HIGH] 再編集用 slideData(saveGenerationState専用・任意)は全レイヤーbase64を含み巨大化する。
      //   PPTX本体はDriveに逃がしたのに、これを google.script.run(約50MB上限)へインライン送信すると巨大デッキで失敗する。
      //   一定サイズ超なら送らない(=デッキ生成・URL返却・履歴登録は成功。再編集用の保存のみ省略)。
      let slideDataString = null;
      try { const s = JSON.stringify(slideData); if (s.length <= 25 * 1024 * 1024) slideDataString = s; } catch (e) { slideDataString = null; }
      const url = await new Promise(function (resolve, reject) {
        google.script.run
          .withSuccessHandler(resolve)
          .withFailureHandler(function (e) { reject(new Error(e && e.message ? e.message : String(e))); })
          .generateDeckFromClientPptx('DRIVE_PPTX:' + fileId, settings || {}, presentationId || null, slideDataString);
      });
      // ★[fix:LOW] 一時PPTXの後始末はサーバ finally(_trashDriveTempFiles=完全削除)に一任する。
      //   ここで再度 trash すると既に削除済みで404になるため行わない(動画アニメ経路は"描画時に読む"ため最後に消す別設計)。
      return url || null;
    } catch (e) {
      console.warn('[PptxFastPath] 失敗、従来経路へフォールバック:', e);
      return null;
    }
  }

  return { buildPages: buildPages, isApplicable: isApplicable, generate: generate };
})();




/**
 * 統合レイアウト生成エンジン (PDF余白拡張.txt のロジックと完全同期版)
 */
const IntegratedLayoutEngine = (function() {
    
    // --- 内部定数 (PDF余白拡張.txt のデフォルト値に準拠) ---
    const PARAMS = {
        scaleFactor: 1.2, // 120% (inputScale: 120)
        tinySize: 20,     // 20px (inputSmooth: 20)
        inset: 8,         // 8px (inputInset: 8)
        edgeFade: 50,     // 50px (inputEdgeFade: 50)
        verticalOffset: 90, // 90% (inputVOffset: 90)
        
        // ★追加: ロゴ消去設定 (デフォルト値)
        eraserEnabled: true,
        eraserRect: {
            w: 210, // eWidth
            h: 40,  // eHeight
            r: 0,   // eRight
            b: 0    // eBottom
        }
    };

    // --- 内部ヘルパー関数 (Pixel Manipulation) ---
    function cleanEdgeData(imageData) {
        const data = imageData.data;
        const len = data.length;
        const colorCounts = {};
        let maxCount = 0;
        let dom = {r:255, g:255, b:255};
        const q = 15; 
        
        for(let i=0; i<len; i+=4) {
            const k = `${Math.round(data[i]/q)*q},${Math.round(data[i+1]/q)*q},${Math.round(data[i+2]/q)*q}`;
            colorCounts[k] = (colorCounts[k]||0)+1;
            if(colorCounts[k] > maxCount) { maxCount = colorCounts[k]; dom = {r:data[i], g:data[i+1], b:data[i+2]}; }
        }
        const threshold = 10;
        for(let i=0; i<len; i+=4) {
            const dist = Math.sqrt(Math.pow(data[i]-dom.r,2) + Math.pow(data[i+1]-dom.g,2) + Math.pow(data[i+2]-dom.b,2));
            if(dist > threshold) { data[i] = dom.r; data[i+1] = dom.g; data[i+2] = dom.b; data[i+3] = 255; }
        }
        return imageData;
    }

    function createStrip(sourceCtx, sx, sy, sw, sh) {
        const raw = sourceCtx.getImageData(sx, sy, sw, sh);
        const clean = cleanEdgeData(raw);
        const t = document.createElement('canvas');
        t.width = sw; t.height = sh;
        t.getContext('2d').putImageData(clean, 0, 0);
        return t;
    }

    // --- メイン画像生成処理 (1ページ分) ---
    async function processPage(pdfPage) {
        // ★変更: scaleを 1.5 -> 2.0 に変更 (高機能版に合わせる) 
        const viewport = pdfPage.getViewport({ scale: 2.0 });
        const sourceCanvas = document.createElement('canvas');
        sourceCanvas.width = viewport.width;
        sourceCanvas.height = viewport.height;
        
        const sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true });
        await pdfPage.render({ canvasContext: sourceCtx, viewport: viewport }).promise;

        // Target Setup
        const newWidth = Math.ceil(sourceCanvas.width * PARAMS.scaleFactor);
        const newHeight = Math.ceil(sourceCanvas.height * PARAMS.scaleFactor);
        const paddingX = Math.floor((newWidth - sourceCanvas.width) / 2);
        const availableSpaceY = newHeight - sourceCanvas.height;
        const paddingY = Math.floor(availableSpaceY * (PARAMS.verticalOffset / 100));

        // 1. Generate Background Canvas
        const bgCanvas = document.createElement('canvas');
        bgCanvas.width = newWidth; bgCanvas.height = newHeight;
        const bgCtx = bgCanvas.getContext('2d');
        bgCtx.fillStyle = '#FFFFFF'; bgCtx.fillRect(0, 0, newWidth, newHeight);

        // Strips Logic
        const origW = sourceCanvas.width;
        const origH = sourceCanvas.height;
        const inset = PARAMS.inset;

        const topS = createStrip(sourceCtx, 0, inset, origW, 1);
        const botS = createStrip(sourceCtx, 0, origH - 1 - inset, origW, 1);
        const leftS = createStrip(sourceCtx, inset, 0, 1, origH);
        const rightS = createStrip(sourceCtx, origW - 1 - inset, 0, 1, origH);

        const tempBg = document.createElement('canvas');
        tempBg.width = newWidth;
        tempBg.height = newHeight;
        const tbCtx = tempBg.getContext('2d');
        tbCtx.fillStyle='#FFFFFF'; tbCtx.fillRect(0,0,newWidth,newHeight);
        
        tbCtx.drawImage(topS, 0, 0, topS.width, topS.height, 0, 0, newWidth, newHeight/2);
        tbCtx.drawImage(botS, 0, 0, botS.width, botS.height, 0, newHeight/2, newWidth, newHeight/2);
        tbCtx.save();
        tbCtx.globalAlpha = 0.5;
        tbCtx.drawImage(leftS, 0, 0, leftS.width, leftS.height, 0, 0, newWidth/2, newHeight);
        tbCtx.drawImage(rightS, 0, 0, rightS.width, rightS.height, newWidth/2, 0, newWidth/2, newHeight);
        tbCtx.restore();

        const tinyBg = document.createElement('canvas');
        tinyBg.width = PARAMS.tinySize; tinyBg.height = PARAMS.tinySize;
        const tCtx = tinyBg.getContext('2d');
        tCtx.imageSmoothingEnabled = true; tCtx.imageSmoothingQuality = 'high';
        tCtx.drawImage(tempBg, 0, 0, PARAMS.tinySize, PARAMS.tinySize);

        // Draw Final Background
        bgCtx.imageSmoothingEnabled = true;
        bgCtx.imageSmoothingQuality = 'high';
        const bleed = 50;
        bgCtx.drawImage(tinyBg, -bleed, -bleed, newWidth + bleed*2, newHeight + bleed*2);

        // 2. Merged Image Generation
        const mergedCanvas = document.createElement('canvas');
        mergedCanvas.width = newWidth;
        mergedCanvas.height = newHeight;
        const mCtx = mergedCanvas.getContext('2d');
        mCtx.drawImage(bgCanvas, 0, 0); // Base with background

        // Source preparation (Masking & Eraser)
        const compositeSource = document.createElement('canvas');
        compositeSource.width = origW; compositeSource.height = origH;
        const csCtx = compositeSource.getContext('2d');
        csCtx.drawImage(sourceCanvas, 0, 0);

        // ★追加: ロゴ消去ロジック (Eraser) 
        if (PARAMS.eraserEnabled) {
            const r = PARAMS.eraserRect;
            // 右下基準で座標計算
            const rx = origW - r.r - r.w;
            const ry = origH - r.b - r.h;
            
            csCtx.globalCompositeOperation = 'destination-out';
            csCtx.fillRect(rx, ry, r.w, r.h);
            csCtx.globalCompositeOperation = 'source-over';
        }

        // ★変更: 境界ぼかしロジック (Edge Fade) 
        // 以前の shadowBlur 方式から、グラデーションマスク方式に変更
        if (PARAMS.edgeFade > 0) {
            const f = PARAMS.edgeFade;
            const maskCanvas = document.createElement('canvas');
            maskCanvas.width = origW; maskCanvas.height = origH;
            const maskCtx = maskCanvas.getContext('2d');
            
            maskCtx.clearRect(0, 0, origW, origH);
            // Solid Center
            maskCtx.fillStyle = 'black';
            maskCtx.fillRect(f, f, origW - 2*f, origH - 2*f);
            
            // Gradient Edges (上下左右の線形グラデーション)
            const gL = maskCtx.createLinearGradient(0, 0, f, 0);
            gL.addColorStop(0, 'rgba(0,0,0,0)'); gL.addColorStop(1, 'black');
            maskCtx.fillStyle = gL; maskCtx.fillRect(0, f, f, origH - 2*f);

            const gR = maskCtx.createLinearGradient(origW - f, 0, origW, 0);
            gR.addColorStop(0, 'black'); gR.addColorStop(1, 'rgba(0,0,0,0)');
            maskCtx.fillStyle = gR; maskCtx.fillRect(origW - f, f, f, origH - 2*f);
            
            const gT = maskCtx.createLinearGradient(0, 0, 0, f);
            gT.addColorStop(0, 'rgba(0,0,0,0)'); gT.addColorStop(1, 'black');
            maskCtx.fillStyle = gT; maskCtx.fillRect(f, 0, origW - 2*f, f);
            
            const gB = maskCtx.createLinearGradient(0, origH - f, 0, origH);
            gB.addColorStop(0, 'black'); gB.addColorStop(1, 'rgba(0,0,0,0)');
            maskCtx.fillStyle = gB; maskCtx.fillRect(f, origH - f, origW - 2*f, f);

            // Gradient Corners (四隅の円形グラデーション)
            const drawCorner = (cx, cy, startA, endA) => {
                const radG = maskCtx.createRadialGradient(cx, cy, 0, cx, cy, f);
                radG.addColorStop(0, 'black'); radG.addColorStop(1, 'rgba(0,0,0,0)');
                maskCtx.fillStyle = radG; maskCtx.beginPath(); maskCtx.moveTo(cx, cy); maskCtx.arc(cx, cy, f, startA, endA); maskCtx.fill();
            };
            drawCorner(f, f, Math.PI, 1.5*Math.PI); // 左上
            drawCorner(origW-f, f, 1.5*Math.PI, 2*Math.PI); // 右上
            drawCorner(origW-f, origH-f, 0, 0.5*Math.PI); // 右下
            drawCorner(f, origH-f, 0.5*Math.PI, Math.PI); // 左下

            // マスク適用
            csCtx.globalCompositeOperation = 'destination-in';
            csCtx.drawImage(maskCanvas, 0, 0);
            csCtx.globalCompositeOperation = 'source-over';
        }

        mCtx.drawImage(compositeSource, paddingX, paddingY);

        // Get Data URLs
        const bgData = bgCanvas.toDataURL('image/jpeg', 0.85);
        const mergedData = mergedCanvas.toDataURL('image/jpeg', 0.85);

        // Cleanup
        [sourceCanvas, bgCanvas, mergedCanvas, tempBg, tinyBg, compositeSource].forEach(c => c.remove());
        return { bgData, mergedData };
    }

    // --- 公開メソッド: PDF全体を処理してslideDataを生成 ---
    async function processPdfAndGenerateSlides(pdfDoc, titleHint) {
        const totalPages = pdfDoc.numPages;
        const slides = [];
        const today = new Date().toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '.');

        // Page 1: Title Slide
        const page1 = await pdfDoc.getPage(1);
        const result1 = await processPage(page1);
        
        slides.push({
            type: "integratedTitle",
            title: titleHint || "プレゼンテーション",
            date: today,
            backgroundImage: result1.bgData,
            notes: "本日はこちらの資料に沿ってご説明いたします。"
        });

        // Page 2+: Content Slides
        for (let i = 2; i <= totalPages; i++) {
            const page = await pdfDoc.getPage(i);
            const result = await processPage(page);
            
            slides.push({
                type: "integratedContent",
                title: `Page ${i}`,
                backgroundImage: result.bgData,
                image: result.mergedData,
                notes: "" 
            });
        }

        // Closing Slide
        slides.push({
            type: "closing",
            notes: "以上で説明を終わります。"
        });

        return slides;
    }

    return {
        process: processPdfAndGenerateSlides,
        processSinglePage: processPage
    };
})();



/**
     * ▼▼▼ [修正] 動画ファイル選択モーダル ▼▼▼
     * ユーザーに動画のアップロード/ペーストを促すモーダルを表示し、
     * Fileオブジェクトを返すPromiseを生成する
     * @returns {Promise<File|null>} 解決されるとFileオブジェクトを返す
     */
    function promptForVideo() {
      return new Promise((resolve, reject) => {
        // 1. 参照するIDを "video..." に変更
        const modal = $('videoPromptModal');
        const promptElement = $('videoPromptText');
        const dropZone = $('videoDropZone');
        const videoUpload = $('videoUpload'); // 既存の <input type="file" id="videoUpload"> を使用 
        const preview = $('videoPreview'); // <video> タグ
        const dropZoneText = $('videoDropZoneText');
        const qualityContainer = $('videoQualityContainer');
        const qualitySelect = $('videoQualitySelect');
        // ★[新1] アニメ変換種類(APNG/GIF)の初期値を、保存済み設定(#animationFormat)に合わせる(無ければAPNG)
        try { if ($('videoAnimFormatSelect')) $('videoAnimFormatSelect').value = ($('animationFormat') && $('animationFormat').value === 'gif') ? 'gif' : 'apng'; } catch (e) {}
        const fileSelectLink = $('fileSelectLinkVideo'); // リンクのID
        const confirmBtn = $('confirmVideoBtn');
        const cancelBtn = $('cancelVideoBtn');

        // 2. プロンプトテキストを設定 (promptForImage と違い、引数なし)
        promptElement.innerHTML = `タイムスタンプから画像を切り出すための<strong>動画ファイル</strong>を選択してください。`;
        
        // 3. プレビューとボタンを初期化
        preview.style.display = 'none'; 
        preview.src = ''; // videoのsrcをクリア
        dropZoneText.style.display = 'block';
        if(qualityContainer) qualityContainer.style.display = 'none';
        confirmBtn.disabled = true;
        modal.style.display = 'flex';

        let videoFile = null; // ★ imageData から videoFile に変更

        // 4. handleFile 関数を動画用に変更
        function handleFile(file) {
          if (!file || !file.type.startsWith('video/')) { // ★ 'image/' から 'video/' に変更
            showStatus('エラー: 動画ファイルを選択してください。', 'error');
            return;
          }
          
          videoFile = file; // ★ Fileオブジェクトを保持
          
          // <video> タグでのプレビューロジック
          const reader = new FileReader();
          reader.onload = (e) => {
            preview.src = e.target.result; // Data URLを video.src に設定
            preview.style.display = 'block';
            dropZoneText.style.display = 'none';
            if(qualityContainer) qualityContainer.style.display = 'block';
            confirmBtn.disabled = false;
          };
          reader.onerror = () => reject(new Error('ファイルの読み込みに失敗しました。'));
          reader.readAsDataURL(file);
        }

        // 5. イベントリスナーを設定 (参照IDを変更)
        const clickHandler = (e) => {
          e.preventDefault(); // aタグのデフォルト動作を防ぐ
          videoUpload.click(); // videoUpload をトリガー
        };
        const changeHandler = () => { 
          if (videoUpload.files.length > 0) handleFile(videoUpload.files[0]); 
        };
        const dragOverHandler = (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); };
        const dragLeaveHandler = () => dropZone.classList.remove('drag-over');
        const dropHandler = (e) => {
          e.preventDefault();
          dropZone.classList.remove('drag-over');
          if (e.dataTransfer.files && e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
        };
        const pasteHandler = (e) => {
          const items = (e.clipboardData || window.clipboardData).items;
          for (const item of items) {
            if (item.kind === 'file' && item.type.startsWith('video/')) { // ★ 'video/' に変更
              e.preventDefault();
              handleFile(item.getAsFile()); 
              break;
            }
          }
        };
        
        // 6. 確定/キャンセル/クリーンアップ (resolveする値を変更)
        const confirmHandler = () => { 
          cleanup();
          const quality = qualitySelect ? parseInt(qualitySelect.value, 10) : 480;
          resolve({ file: videoFile, quality: quality });
        };
        const cancelHandler = () => { 
          cleanup(); 
          reject(new Error('動画なしで続行')); 
        };
        
        function cleanup() {
          fileSelectLink.removeEventListener('click', clickHandler); // ★ 参照ID変更
          videoUpload.removeEventListener('change', changeHandler); // ★ 参照ID変更
          dropZone.removeEventListener('dragover', dragOverHandler);
          dropZone.removeEventListener('dragleave', dragLeaveHandler);
          dropZone.removeEventListener('drop', dropHandler);
          document.removeEventListener('paste', pasteHandler);
          confirmBtn.removeEventListener('click', confirmHandler);
          cancelBtn.removeEventListener('click', cancelHandler);
          modal.style.display = 'none';
          videoUpload.value = ''; // ★ 参照ID変更
          // videoプレビューのsrcもクリア
          if (preview.src) {
             // URL.revokeObjectURL(preview.src); // Data URLなのでrevoke不要
             preview.src = '';
          }
        }
        
        fileSelectLink.addEventListener('click', clickHandler); // ★ 参照ID変更
        videoUpload.addEventListener('change', changeHandler); // ★ 参照ID変更
        dropZone.addEventListener('dragover', dragOverHandler);
        dropZone.addEventListener('dragleave', dragLeaveHandler);
        dropZone.addEventListener('drop', dropHandler);
        document.addEventListener('paste', pasteHandler);
        confirmBtn.addEventListener('click', confirmHandler);
        cancelBtn.addEventListener('click', cancelHandler);
      });
    }


    
/**
 * [修正版] 構成データエディタで現在表示中のページのみを更新します。
 * 1. 最初に SlideEditor の最新データを textarea に書き戻す
 * 2. textarea から最新のデータを読み込んで処理を実行する
 * 3. 画像タイプ判定ロジックを修正し、分割データとフラット化データを正しく区別する
 * 4. [修正] データ埋め込み時の条件分岐を排他制御にし、分割データの上書きバグを防止
 * 5. [修正] レイヤー画像のプロパティ名不一致（data vs dataBase64）を解消
 */
async function updateCurrentPage() {
  // ★[要望4] 排他制御: スライド全体の生成/更新が走っている間はページ単位更新を実行しない
  if (window.isSlideJobRunning) { try { showStatus('処理中です。完了までお待ちください。', 'info'); } catch (e) {} return; }
  // SlideEditorがアクティブでなければ何もしない
  if (!SlideEditor || typeof SlideEditor.getCurrentIndex !== 'function' || typeof SlideEditor.getSlideData !== 'function') {
    showStatus('エラー: ページ更新機能が初期化されていません。', 'error');
    return;
  }
  if (!SlideEditor.isEditorActive()) {
    showStatus('構成データ編集モード中にのみページ更新が可能です。', 'info');
    return;
  }

  // ▼▼▼ ボタン制御 ▼▼▼
  const updateBtn = $('updatePageBtn');
  const originalBtnText = updateBtn.querySelector('.btn-text').textContent;
  updateBtn.disabled = true; setSlideJobRunning(true, 'updatePageBtn'); /* ★[要望4] 実行中は「スライドを更新/生成」ボタンをロック */
  let loadingToastId = null;
  let finalSingleSlideDataString = null; // 最終的にサーバーに送るJSON文字列

  // ▼▼▼ 編集モードからJSONデータをtextareaに書き戻す ▼▼▼
  console.log('[updateCurrentPage] 編集モードのため、SlideEditorからJSONデータをtextareaに書き戻します。');
  try {
      const currentEditorData = SlideEditor.getSlideData();
      const jsonString = JSON.stringify(currentEditorData, null, 2);
      $('slideDataInput').value = jsonString;
      console.log('[updateCurrentPage] textareaの更新完了。');
  } catch (e) {
      console.error('[updateCurrentPage] SlideEditorデータのJSON文字列化に失敗:', e);
      showJsonErrorModal('編集内容のJSON変換に失敗しました: ' + e.message);
      updateBtn.disabled = false; setSlideJobRunning(false); /* ★[要望4] 排他解除(両ボタン復帰) */
      updateBtn.textContent = originalBtnText;
      return; 
  }
  
  const currentIndex = SlideEditor.getCurrentIndex();
  let currentSingleSlideData;

  try {
    // textarea から配列全体をパース
    const fullData = JSON.parse($('slideDataInput').value);
    // 対象インデックスのデータをディープコピー
    currentSingleSlideData = JSON.parse(JSON.stringify(fullData[currentIndex])); 
    
    // 配列内の空文字列をフィルタリング
    currentSingleSlideData = filterEmptyStringsInArrays(currentSingleSlideData);
    console.log(`[updateCurrentPage] 空文字列をフィルタリング後のスライドデータ:`, currentSingleSlideData.type);

  } catch (e) {
     showStatus('エラー: 更新対象のスライドデータの読み込みに失敗しました。', 'error');
     updateBtn.disabled = false; setSlideJobRunning(false); /* ★[要望4] 排他解除(両ボタン復帰) */
     updateBtn.textContent = originalBtnText;
     return;
  }

  if (currentIndex < 0 || !currentSingleSlideData) {
    showStatus('エラー: 更新対象のスライドが見つかりません。', 'error');
    updateBtn.disabled = false; setSlideJobRunning(false); /* ★[要望4] 排他解除(両ボタン復帰) */
    updateBtn.textContent = originalBtnText;
    return;
  }

  // プレゼンテーションIDを取得
  let presentationId = null;
  if (lastGeneratedSlideUrl) {
    const match = lastGeneratedSlideUrl.match(/\/d\/([a-zA-Z0-9_-]+)|\?id=([a-zA-Z0-9_-]+)/);
    if (match && (match[1] || match[2])) {
      presentationId = match[1] || match[2];
    }
  }

  if (!presentationId) {
    showStatus('エラー: 更新対象のプレゼンテーションが見つかりません。まず全体を生成してください。', 'error');
    updateBtn.disabled = false; setSlideJobRunning(false); /* ★[要望4] 排他解除(両ボタン復帰) */
    updateBtn.textContent = originalBtnText;
    return;
  }

  try {
    const imageUpdateOption = 'update';
    console.log("[updateCurrentPage] 画像オプションは 'update' に固定されます。(モーダルスキップ)");

    startButtonTimer(updateBtn);
    const settings = getCurrentSettings();

    if (ENABLE_CREDIT_IMAGE === true) {
      // ★ クレジットはテキストで配置(画像化しない)。文言だけを選んでサーバーに渡す。
      const creditText = CREDIT_TEXT_OPTIONS[Math.floor(Math.random() * CREDIT_TEXT_OPTIONS.length)];
      settings.creditText = creditText;
      settings.creditImageBase64 = null;
      console.log('[updateCurrentPage] クレジットテキスト:', creditText);
    } else {
      settings.creditText = null;
      settings.creditImageBase64 = null;
    }

    // --- テキスト幅計算 ---
    console.log('[updateCurrentPage] テキスト幅を計算中...');
    try {
      const configSizes = typeof CONFIG !== 'undefined' ? CONFIG.FONTS.sizes : {};
      const fontFamily = settings.fontFamily || 'Noto Sans JP';
      
      if (currentSingleSlideData && typeof currentSingleSlideData.title === 'string' && currentSingleSlideData.title.trim() !== '') {
        let fontSizePt;
        let fontWeight = 'bold';
        if (currentSingleSlideData.type === 'title') fontSizePt = configSizes.title || 41;
        else if (currentSingleSlideData.type === 'section') fontSizePt = configSizes.sectionTitle || 38;
        else fontSizePt = configSizes.contentTitle || 24;

        const widthPt = calculateTextWidthPt(currentSingleSlideData.title, fontSizePt, fontFamily, fontWeight);
        if (widthPt !== null) {
          currentSingleSlideData._title_widthPt = widthPt;
        } else {
          console.warn(`[updateCurrentPage] Title 幅計算失敗`);
        }
      }
      
      if (currentSingleSlideData && typeof currentSingleSlideData.subhead === 'string' && currentSingleSlideData.subhead.trim() !== '') {
        const fontSizePt = configSizes.subhead || 16;
        const fontWeight = 'normal';
        const widthPt = calculateTextWidthPt(currentSingleSlideData.subhead, fontSizePt, fontFamily, fontWeight);
        if (widthPt !== null) {
          currentSingleSlideData._subhead_widthPt = widthPt;
        } else {
          console.warn(`[updateCurrentPage] Subhead 幅計算失敗`);
        }
      }
      console.log('[updateCurrentPage] テキスト幅計算完了');
    } catch (e) {
      console.error('[updateCurrentPage] テキスト幅計算中にエラー:', e);
    }

    // --- ゴースト番号画像生成 ---
    if (imageUpdateOption === 'update' && currentSingleSlideData.type === 'section') {
        const sectionNo = currentSingleSlideData.sectionNo;
        const sectionNoString = String(sectionNo || '').trim();
        
        if (sectionNo === null || sectionNo === undefined || sectionNoString === '') {
            console.log('[updateCurrentPage] sectionNo が空のため、透明なゴースト画像を挿入します。');
            currentSingleSlideData.ghostImageBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
        } else {
            console.log('[updateCurrentPage] --- ゴースト番号画像生成開始 ---');
            try {
                const ghostSettings = {
                    baseFrequency: 0.01,
                    colorType: 'gray',
                    alphaSlope: 0.50,
                    contrastSlope: 1.0,
                    frontColor: settings.primaryColor,
                    frontAlpha: ((+settings.ghostTopAlpha || 0.35) + (+settings.ghostBottomAlpha || 0.2)) / 2
                };
                const parsedNum = (() => {
                    if (Number.isFinite(sectionNo)) { return Number(sectionNo); }
                    if (typeof sectionNo === 'string' || typeof sectionNo === 'number') {
                        const parsedStringNum = parseFloat(sectionNoString);
                        if (Number.isFinite(parsedStringNum)) return parsedStringNum;
                    }
                    const m = String(currentSingleSlideData.title || '').match(/^\s*(\d+)[\.．]/);
                    if (m && m[1]) { return Number(m[1]); }
                    return (currentIndex + 1);
                })();

                const numberString = String(parsedNum).padStart(2, '0');
                const svgString = generateGhostNumberSvg(numberString, 400, 250, ghostSettings);
                const pngBase64 = await convertSvgToBase64Png(svgString);
                currentSingleSlideData.ghostImageBase64 = pngBase64;
                console.log('[updateCurrentPage] ゴースト番号画像生成完了');
            } catch (e) {
                console.warn('[updateCurrentPage] ゴースト番号画像生成プロセスでエラー:', e);
                showStatus('セクション番号を代替表示にしました', 'warning', 5000);
                currentSingleSlideData.ghostImageBase64 = null;
            }
        }
    }

    // --- Triangle矢印画像生成 ---
    if (imageUpdateOption === 'update' && currentSingleSlideData.type === 'triangle') {
        console.log('[updateCurrentPage] --- Triangle矢印画像生成開始 ---');
        try {
            const triangleArrowData = await generateTriangleArrowImages(settings);
            settings.triangleArrows = triangleArrowData;
            console.log('[updateCurrentPage] Triangle矢印画像生成完了');
        } catch (e) {
            console.error('[updateCurrentPage] Triangle矢印画像生成プロセス全体でエラー:', e);
            showStatus('矢印を代替表示にしました', 'warning', 5000);
            settings.triangleArrows = null;
        }
    } else {
        settings.triangleArrows = null;
    }


    // --- 画像/Prompt/Timestamp/SVG/Chart 処理 ---
    if (imageUpdateOption === 'update') {
        console.log('[updateCurrentPage] --- 画像要素を分類・処理中 (画像更新モード) ---');
        
        const urlRegex = /^(https?:\/\/|data:image\/)/;
        const timestampRegex = /^\d{2}:\d{2}(\.\S)?.*$/;
        const notebookLmRegex = /^NotebookLM(\d+)$/i;
        const svgRegex = /^\s*<svg/i;

        // [互換修正] {chartType:'svg', data:'<svg…>'} 形式（旧Geminiが生SVGをチャートオブジェクトで包んだ形）を生SVG文字列へ正規化
        if (currentSingleSlideData.image && typeof currentSingleSlideData.image === 'object'
            && currentSingleSlideData.image.chartType === 'svg'
            && typeof currentSingleSlideData.image.data === 'string'
            && /^\s*<svg/i.test(currentSingleSlideData.image.data)) {
          currentSingleSlideData.image = currentSingleSlideData.image.data;
        }
        const imageValue = currentSingleSlideData.image;
        let identifiedType = null;
        let textKey = null;

        // 1. タイプの識別
        if (typeof imageValue === 'string' && imageValue.trim() !== '') {
            const trimmedValue = imageValue.trim();
            textKey = trimmedValue;
            if (svgRegex.test(trimmedValue)) identifiedType = 'svg';
            else if (timestampRegex.test(trimmedValue)) identifiedType = 'timestamp';
            else if (notebookLmRegex.test(trimmedValue)) identifiedType = 'notebooklm';
            else if (urlRegex.test(trimmedValue)) identifiedType = 'url';
            else if (trimmedValue.startsWith('{')) {
                try {
                    const parsedJson = JSON.parse(trimmedValue);
                    identifiedType = (parsedJson && typeof parsedJson === 'object' && parsedJson.chartType) ? 'chartJson' : 'prompt';
                } catch (e) { identifiedType = 'prompt'; }
            } else identifiedType = 'prompt';
        } else if (typeof imageValue === 'object' && imageValue !== null && imageValue.chartType) {
            identifiedType = 'chartObject';
            try { textKey = JSON.stringify(imageValue); } catch (e) { console.error("[updateCurrentPage] chartObjectの文字列化失敗:", imageValue, e); }
        } else if (typeof imageValue === 'object' && imageValue !== null) {
            
            if (imageValue.info === 'chart' || imageValue.info === 'animation') {
                identifiedType = 'processed';
                console.log('[updateCurrentPage] 画像は既に処理済み (Chart/Animation) です。');
            }
            // ▼▼▼ [修正] 分割データの判定を優先する ▼▼▼
            else if (imageValue.layers && Array.isArray(imageValue.layers)) {
                identifiedType = 'processed_split'; // 分割データ
                console.log('[updateCurrentPage] 画像は既に処理済み (Split Image) です。');
            } 
            // ▲▲▲ [修正] ここまで ▲▲▲
            else if (imageValue.data && imageValue.originalData) {
                // フラット化された画像
                identifiedType = 'processed_flat';
                console.log('[updateCurrentPage] 画像は既に処理済み (Flat Image) です。');
            }
        }

        console.log(`[updateCurrentPage] Identified image type: ${identifiedType}`);
        
        let processedImageInfo = null; // { data: base64, type: ... }

        // 2. タイプに応じた処理を実行
        
        if (identifiedType === 'processed') {
             processedImageInfo = { data: imageValue.data, type: imageValue.info };
        }
        // ▼▼▼ [追加] 分割データの場合の処理 ▼▼▼
        else if (identifiedType === 'processed_split') {
             // そのまま渡す
             processedImageInfo = { 
                 type: 'split_layout_data',
                 bgData: imageValue.backgroundImage,
                 layers: imageValue.layers,
                 originalWidth: imageValue.originalWidth,
                 originalHeight: imageValue.originalHeight,
                 // ★追加: 元画像データを引き継ぐ
                 data: imageValue.data || imageValue.originalData
             };
        }
        // ▲▲▲ [追加] ここまで ▲▲▲
        else if (identifiedType === 'processed_flat') {
             // フラット画像の場合
             processedImageInfo = { data: imageValue.data, type: 'flat_image' };
        }
        else if (identifiedType === 'chartJson' || identifiedType === 'chartObject') {
            console.log('[updateCurrentPage] --- グラフJSON -> SVG -> PNG/APNG 変換開始 ---');
            const originalJsonString = textKey;
            const graphTheme = settings.graphColorTheme;
            let processedJsonString = originalJsonString;

            if (graphTheme !== 'original') {
                console.log(`[updateCurrentPage] グラフテーマ '${graphTheme}' を適用中...`);
                let replacementColor;
                switch (graphTheme) {
                    case 'primary': replacementColor = settings.primaryColor; break;
                    case 'gemini': replacementColor = "#gemini"; break;
                    case 'night': replacementColor = "#night"; break;
                }
                try {
                    const chartData = JSON.parse(originalJsonString);
                    if (chartData && chartData.data) chartData.data.colors = replacementColor;
                    processedJsonString = JSON.stringify(chartData);
                } catch (e) { /* ignore */ }
            }

            try {
                const animationEnabled = settings.enableGraphAnimation;
                if (animationEnabled) {
                    console.log(`[updateCurrentPage] アニメーショングラフ生成開始...`);
                    // ★[新規I] 途中経過トーストは抑制(ざっくり化)
                    // showStatus('アニメーショングラフを生成中...', 'info', 10000);
                    const animationFrames = 11;
                    const animationDurationMs = 1500;
                    const animationEndDelayMs = 3500;
                    const frameDelayMs = animationDurationMs / (animationFrames - 1);
                    const delays = [];
                    for (let i = 0; i < animationFrames - 1; i++) { delays.push(Math.round(frameDelayMs)); }
                    delays.push(animationEndDelayMs);
                    const jsonFramesStrings = generateAnimationJsonFrames(processedJsonString, animationFrames);
                    
                    const frameSvgItems = await new Promise((resolve, reject) => {
                        google.script.run
                            .withSuccessHandler(resolve)
                            .withFailureHandler(reject)
                             .convertJsonToSvgBatch(jsonFramesStrings);
                    });
                    const pngFramesBase64 = [];
                    for (const item of frameSvgItems) {
                        if (item.svg) {
                            const staticSvg = await renderDynamicSvgInIframe(item.svg);
                            const pngData = await convertSvgToBase64Png(staticSvg);
                            pngFramesBase64.push(pngData);
                        }
                    }
                    if (pngFramesBase64.length === 0) throw new Error('アニメーションフレームが1枚も生成されませんでした。');
                    const { frames, width, height } = await base64FramesToArrayBuffers(pngFramesBase64);
                    const finalDelays = (frames.length === delays.length) ? delays : delays.slice(0, frames.length);
                    if (frames.length < delays.length) {
                        finalDelays[finalDelays.length - 1] = animationEndDelayMs;
                    }
                    
                    // ★[新規L] アニメグラフの変換形式(APNG/GIF)を #graphAnimFormatSelect で選択
                    const __ganimFmt = ($('graphAnimFormatSelect') && $('graphAnimFormatSelect').value === 'gif') ? 'gif' : 'apng';
                    const apngBase64 = (__ganimFmt === 'gif')
                      ? await createGIFFromFrames(frames, finalDelays, width, height)
                      : await createAPNGFromFrames(frames, finalDelays, width, height);
                    processedImageInfo = { data: apngBase64, type: 'animation' };
                } else {
                    throw new Error("Animation disabled, fallback to static.");
                }

            } catch (animError) {
                console.warn(`[updateCurrentPage] アニメーショングラフ処理エラー (または無効): ${animError.message}。静止画にフォールバックします。`);
                try {
                    const staticSvgItems = await new Promise((resolve, reject) => {
                        google.script.run
                             .withSuccessHandler(resolve)
                             .withFailureHandler(reject)
                            .convertJsonToSvgBatch([processedJsonString]); 
                    });
                    if (!staticSvgItems || staticSvgItems.length === 0 || !staticSvgItems[0].svg) {
                        throw new Error(staticSvgItems[0]?.error || 'サーバーでの静止画SVG変換に失敗しました。');
                    }
                    const staticSvgString = staticSvgItems[0].svg;
                    const staticSvg = await renderDynamicSvgInIframe(staticSvgString);
                    const pngData = await convertSvgToBase64Png(staticSvg);
                    processedImageInfo = { data: pngData, type: 'chart' };
                } catch (staticError) {
                    console.error(`[updateCurrentPage] 静止画グラフのフォールバック処理エラー: ${staticError.message}`);
                    console.error('[update] グラフ画像変換エラー:', staticError); showStatus('グラフの画像化に一部失敗しました（代替表示）', 'warning');
                }
            }
        }
        else if (identifiedType === 'svg') {
            console.log('[updateCurrentPage] --- 既存SVG -> PNG 変換開始 ---');
            try {
                const staticSvg = await renderDynamicSvgInIframe(textKey);
                let filterColor = null;
                if (settings.graphColorTheme === 'primary') {
                   filterColor = settings.primaryColor;
                }
                const pngData = await convertSvgToBase64Png(staticSvg, true, null, filterColor);
                processedImageInfo = { data: pngData, type: 'svg_code' };
            } catch (pngError) {
                console.error(`[updateCurrentPage] SVG -> PNG変換エラー: ${pngError.message}`);
                console.error('SVG画像変換エラー:', pngError); showStatus('図形の画像化に一部失敗しました（代替表示）', 'warning');
            }
        }
        else if (identifiedType === 'notebooklm') {
            console.log('[updateCurrentPage] --- NotebookLM PDF処理開始 ---');
            try {
                const match = textKey.match(notebookLmRegex);
                const pageNum = parseInt(match[1], 10);
                
                let presentationTitle = "プレゼンテーション";
                try {
                    const fullData = JSON.parse($('slideDataInput').value);
                    if (fullData && fullData[0] && fullData[0].title) {
                        presentationTitle = fullData[0].title;
                    }
                } catch(e){}

                const result = await promptForPdfPage(pageNum, presentationTitle);
                if (result.type === 'split_layout') {
                    const splitData = result.data;
                    const bgData = splitData.background;
                    const layers = splitData.layers.map(l => ({
                        data: l.dataBase64, 
                        x: l.x, y: l.y, w: l.width, h: l.height
                    }));
                    processedImageInfo = {
                        type: 'split_layout_data', 
                        bgData: bgData,
                        layers: layers,
                        originalWidth: splitData.originalWidth,
                        originalHeight: splitData.originalHeight
                    };
                }

                let finalImageData = result.data;
                const layoutType = result.layout;

                if (layoutType !== 'pdf_layout' && layoutType !== 'full_layout_with_margins') {
                    finalImageData = await applyRoundedCorners(finalImageData);
                }

                if (layoutType === 'pdf_layout') {
                    currentSingleSlideData.type = 'fullImage';
                    console.log(`[updateCurrentPage] スライドタイプを fullImage に変更 (PDF Layout)`);
                } else if (layoutType === 'full_layout_with_margins') {
                    currentSingleSlideData.type = 'integratedContent';
                    console.log(`[updateCurrentPage] スライドタイプを integratedContent に変更 (With Margins)`);
                }

                processedImageInfo = { data: finalImageData, type: 'notebooklm' };
            } catch (pdfError) {
               if (pdfError.message.includes('なしで続行')) {
                   showStatus('PDF処理をスキップしました。', 'info');
               } else {
                   console.error('[updateCurrentPage] PDF処理エラー:', pdfError);
                   showStatus(`PDF処理エラー: ${pdfError.message}`, 'error');
               }
            }
        }
        else if (identifiedType === 'prompt') {
            console.log('[updateCurrentPage] --- テキストプロンプト -> 画像処理開始 ---');
            try {
                showStatus(`「${textKey}」の画像を指定してください...`, 'info', 10000);
                const originalImageData = await promptForImage(textKey);
                let finalImageData = originalImageData;
                const shouldRound = currentSingleSlideData.type !== 'fullImage' && !currentSingleSlideData.isSummary;
                if (shouldRound) {
                   finalImageData = await applyRoundedCorners(originalImageData);
                }
                processedImageInfo = { data: finalImageData, type: 'prompt' };
            } catch (promptError) {
              if (promptError.message.includes('キャンセル')) {
                console.warn('[updateCurrentPage] 画像プロンプトキャンセル:', textKey);
                throw promptError;
              } else if (promptError.message.includes('なしで続行')) { 
                showStatus(`画像処理をスキップしました。`, 'info');
                console.log(`[updateCurrentPage] 画像がスキップされました: "${textKey}"`);
              } else {
                console.error(`[updateCurrentPage] プロンプト画像処理エラー (${textKey}):`, promptError);
                showStatus(`画像処理エラー: ${promptError.message}`, 'error');
              }
            }
        }
        else if (identifiedType === 'timestamp') {
            console.log('[updateCurrentPage] --- タイムスタンプ -> 動画フレーム処理開始 ---');
            let videoFile = null;
            let videoQuality = 480;
            try {
                showStatus('画像切り出しのため、動画ファイルを選択してください。', 'info', 10000);
                const videoResult = await promptForVideo();
                if (videoResult && videoResult.file) {
                    videoFile = videoResult.file;
                    videoQuality = videoResult.quality;
                }
                if (!videoFile) throw new Error('動画選択がキャンセルされました。');
                
                showStatus('動画から画像の抽出を開始します...', 'info', 60000);
                const extractedFrames = await extractFrames(videoFile, [textKey], videoQuality);
                if (!extractedFrames || extractedFrames.length === 0) throw new Error("フレームの抽出に失敗しました。");

                const frame = extractedFrames[0];
                if (frame.isAnimation) {
                    console.log(`[updateCurrentPage] アニメーション(${frame.timestamp})は角丸処理をスキップします。`);
                    processedImageInfo = { data: frame.data, type: 'animation' };
                } else {
                    const roundedImageData = await applyRoundedCorners(frame.data);
                    processedImageInfo = { data: roundedImageData, type: 'timestamp' };
                }
            } catch (videoError) {
                if (videoError.message.includes('キャンセル')) {
                console.warn('[updateCurrentPage] 動画処理キャンセル');
                throw videoError;
              } else if (videoError.message.includes('なしで続行')) { 
                showStatus('動画処理をスキップしました。', 'info');
                console.warn('[updateCurrentPage] 動画選択がスキップされました。');
              } else {
                console.error('[updateCurrentPage] 動画処理プロセス全体でエラー:', videoError);
                showStatus(`動画処理エラー: ${videoError.message}`, 'error');
              }
            }
        }
        
        // --- 3. 処理結果をスライドデータに埋め込む ---
        if (processedImageInfo) {
            currentSingleSlideData._originalImage = textKey;
            console.log(`[updateCurrentPage] Base64埋め込み実行 (Type: ${processedImageInfo.type})`);
            
            // ▼▼▼ [修正] 分割データの場合
            if (processedImageInfo.type === 'split_layout_data') {
                currentSingleSlideData.type = 'splitImage';
                currentSingleSlideData.image = {
                    backgroundImage: processedImageInfo.bgData, 
                    // ★追加: 元画像データを復元 (履歴保存用)
                    originalData: processedImageInfo.data, 
                    data: processedImageInfo.data,
                    
                    layers: processedImageInfo.layers.map(l => ({
                        ...l, 
                        key: l.data || l.dataBase64 || l.key, 
                        x: l.x, y: l.y, 
                        w: l.w || l.width,
                        h: l.h || l.height
                    })),
                    originalWidth: processedImageInfo.originalWidth,
                    originalHeight: processedImageInfo.originalHeight
                };
            }
            // ▼▼▼ [修正] else if で繋ぐ
            else if (processedImageInfo.type === 'chart' || processedImageInfo.type === 'animation' || processedImageInfo.type === 'svg_code') {
                currentSingleSlideData.image = {
                    "info": processedImageInfo.type,
                    "data": processedImageInfo.data
                };
            } else if (processedImageInfo.type === 'flat_image') {
                // フラット画像の場合は Base64 文字列のみを格納する
                currentSingleSlideData.image = processedImageInfo.data;
            } else {
                // 静止画
                currentSingleSlideData.image = processedImageInfo.data;
            }
        } else if (identifiedType !== 'url' && identifiedType !== 'processed' && identifiedType !== 'processed_flat' && identifiedType !== 'processed_split' && identifiedType !== null) {
            console.warn("[updateCurrentPage] Base64埋め込み失敗: 処理済み画像がありません。");
        } else {
             console.log("[updateCurrentPage] Base64埋め込み不要");
        }
        
    } else {
        console.log('[updateCurrentPage] 画像保持オプションのため、画像分類・処理・埋め込みをスキップ');
    }

    // --- 4. 最終的なJSON文字列を作成 ---
    try {
        finalSingleSlideDataString = JSON.stringify(currentSingleSlideData);
        console.log('[updateCurrentPage] 最終JSON文字列生成成功');
    } catch (e) {
        console.error('[updateCurrentPage] 最終JSON文字列化エラー:', e);
        showStatus('元のデータで処理を続行します', 'warning', 6000);
        finalSingleSlideDataString = JSON.stringify(currentSlideDataArray[currentIndex]);
    }

    // --- 5. バックエンド処理の呼び出し ---
    // ★[テンプレ準備] テンプレ指定時は先にキャッシュを用意する（時間がかかるときだけトーストを出す）
    try {
      const __tplForPrep = (($('templateUrl') && $('templateUrl').value) || '').trim();
      if (__tplForPrep) await _prepareTemplateBeforeGenerate(__tplForPrep);
    } catch (e) { console.warn('[updatePage] テンプレ準備をスキップ:', e); }
    loadingToastId = showStatus(`ページ ${currentIndex + 1} を更新中...`, 'info', 15000);



    // ★★★ キャッシュデータのマージ処理 ★★★
    // JSON(軽量版)をパースしてオブジェクトに戻す
    const dataToSend = JSON.parse(finalSingleSlideDataString); 
    
    const imgKeys = ['image', 'backgroundImage', 'foregroundImage'];
    
    // 各画像キーについて、キャッシュが存在するか確認
    for (const key of imgKeys) {
        if (typeof ImageSplitterTool !== 'undefined' && ImageSplitterTool.hasCache(currentIndex, key)) {
            const cache = ImageSplitterTool.getCache(currentIndex, key);
            
            // A. プリロード済みデータがある場合 (まだ編集していない)
            if (cache.preloadedData) {
                // ★[D3退行防止] 既に text層を持つ(=Editorでテキスト変換済み)keyは preloadedData(image層)で上書きしない。
                //   これをしないと、単一ページ「ページ更新」でテキストが画像へ戻る退行が起きる(全ページ一括liteで分解表示をtext化した後など)。
                const __existingLayers = (dataToSend[key] && dataToSend[key].layers) || [];
                const __hasTextLayer = Array.isArray(__existingLayers) && __existingLayers.some(l => l && l.type === 'text');
                if (__hasTextLayer) {
                    console.log(`[updateCurrentPage] text層ありのためpreloadedDataマージをスキップ(テキスト保持): ${key}`);
                } else {
                    console.log(`[updateCurrentPage] キャッシュ(Preload)からデータをマージ: ${key}`);
                    const splitResult = cache.preloadedData;

                    // splitImage形式に変換して、送信データに上書きセット
                    dataToSend[key] = {
                        backgroundImage: splitResult.background,
                        layers: splitResult.layers,
                        originalWidth: splitResult.originalWidth,
                        originalHeight: splitResult.originalHeight,
                        originalData: cache.currentBase64Image // 元画像も保持
                    };

                    // タイプも強制的に splitImage にする (imageキーの場合)
                    if (key === 'image') dataToSend.type = 'splitImage';
                }
            }
            // B. 編集済みデータ (lastSvgState) がある場合
            // (今回の実装では close() 時に JSON の _cached フラグだけ立てているため、
            //  ここでのマージロジックは A (プリロードデータ) を主に使用します。
            //  ※編集後の完全な復元には、本来 close() 時に layers 構造体もキャッシュする必要がありますが、
            //    今回はプリロードデータのマージを最優先とします)
        }
    }
    
    // マージ後のデータで送信用のJSON文字列を再生成
    const mergedJsonString = JSON.stringify(dataToSend);

    console.log(`[updateCurrentPage] Updating slide...`);
    
    // サーバー呼び出し
    const result = await new Promise((resolve, reject) => {
      google.script.run
        .withSuccessHandler(resolve)
        .withFailureHandler(reject)
        .updateSingleSlide(
          presentationId,
          currentIndex, 
          mergedJsonString, // ★変更: finalSingleSlideDataString ではなく mergedJsonString を送る
          settings,
          imageUpdateOption 
        );
    });



    // --- 6. 成功/失敗のハンドリング ---
    if (result && result.status === 'success') {
      if (loadingToastId) {
        const loadingToast = $(loadingToastId);
        if (loadingToast) {
          loadingToast.remove();
          console.log('[updateCurrentPage] 「更新中」トーストを強制削除しました。');
        }
      }

      showStatus(`ページ ${currentIndex + 1} の更新が完了しました！`, 'success');
      console.log(`[updateCurrentPage] Slide ${currentIndex + 1} updated successfully.`); 
      
    } else {
      throw new Error(result.message || '不明なサーバーエラーが発生しました。');
    }

  } catch (error) {
    if (error && error.message && error.message.includes('キャンセル')) {
      showStatus('ページ更新がキャンセルされました。', 'info');
      console.log('[updateCurrentPage] User cancelled update.');
    } else {
      console.error('[updatePage] エラー:', error); showStatus('ページ更新でエラーが発生しました', 'error', 8000);
      console.error('[updateCurrentPage] Error:', error);
    }
  } finally {
    const newButtonText = `${currentIndex + 1}ページ目を更新`;
    stopButtonTimer(updateBtn, newButtonText);
    updateBtn.disabled = false; setSlideJobRunning(false); /* ★[要望4] 排他解除(両ボタン復帰) */
  }
}





    function toggleGradientOptions() {
      var optionsDiv = $('gradient-options');
      // var warn = $('gradientWarning'); // 削除
      if ($('enableGradient').checked) {
        optionsDiv.style.display = 'block';
        // if (warn) warn.style.display = 'block'; // 削除
      } else {
        optionsDiv.style.display = 'none';
        // if (warn) warn.style.display = 'none'; // 削除
      }
    }

    // ★[新規L] アニメーショングラフのチェックON時のみ、APNG/GIF選択リストを表示
    function toggleGraphAnimFormat() {
      var cb = $('enableGraphAnimation'), sel = $('graphAnimFormatSelect');
      if (cb && sel) sel.style.display = cb.checked ? '' : 'none';
    }

    function loadSettingsToForm() {
      if (Object.keys(loadedSettings).length > 0) {
        applyAllSettings(loadedSettings);
      } else {
        updatePrimaryColor($('primaryColorText').value);
        updateLargeFontColor($('largeFontColorText').value);
        updateSmallFontColor($('smallFontColorText').value);
        updateBackgroundColor($('backgroundColorText').value);
      }
      if (enableUiColorSync === false) {
        console.log('UIカラー連動がオフのため、デフォルトのUIカラーを強制適用します。');
        
        // ※この時点で applyAllSettings や update...Color が実行され、
        // フォームの入力値は設定ファイルの値（例: #AAAAAA）に更新されていますが、
        // CSS変数も（ガード節により）更新されていないか、
        // もし更新されていても、ここでデフォルト値（青）に上書きされます。

        const defaultPrimary = '#4285F4';
        // #4285F4から計算したダーク版（のロジック）
        const defaultPrimaryDark = '#3871CF'; 
        const defaultLargeFont = '#333333';
        const defaultSmallFont = '#1F2937';
        // #FFFFFF の場合のデフォルト背景色
        const defaultBg = '#F9FAFB'; 

        document.documentElement.style.setProperty('--primary-color', defaultPrimary);
        document.documentElement.style.setProperty('--primary-color-dark', defaultPrimaryDark);
        document.documentElement.style.setProperty('--text-large-font', defaultLargeFont);
        document.documentElement.style.setProperty('--text-small-font', defaultSmallFont);
        
        // 狭幅レイアウトでない場合のみ背景色をリセット
        const leftPane = $('left-pane');
        const isNarrow = leftPane && leftPane.classList.contains('narrow-layout');
        if (!isNarrow) {
            document.documentElement.style.setProperty('--bg-body', defaultBg);
            document.documentElement.style.setProperty('--card-header-bg', defaultBg);
        }
      }
    }

    // ★[新2] テンプレートスライドURLの有効性を自動チェックし、余白設定の表示/非表示を制御する
    function _wireTemplateUrlValidation() {
      const inp = $('templateUrl');
      if (!inp || inp.dataset.tplValWired) return;
      inp.dataset.tplValWired = '1';
      let timer = null;
      inp.addEventListener('input', () => { if (timer) clearTimeout(timer); timer = setTimeout(validateTemplateUrl, 600); });
    }
    function validateTemplateUrl() {
      const inp = $('templateUrl');
      const statusEl = $('templateUrlStatus');
      const marginGroup = $('templateMarginGroup');
      if (!inp) return;
      const val = (inp.value || '').trim();
      const showMargin = (show) => { if (marginGroup) marginGroup.style.display = show ? '' : 'none'; };
      // ★[新規E] 項目名(ラベル)の右側に表示するため文言は簡潔に。フル文言は title(ホバー)で参照可能に。
      const setStatus = (text, color, full) => { if (statusEl) { statusEl.textContent = text || ''; statusEl.style.color = color || ''; statusEl.style.display = text ? '' : 'none'; statusEl.title = full || text || ''; } };
      if (!val) { setStatus('', ''); showMargin(false); return; }
      if (!/[-\w]{25,}/.test(val)) { setStatus('⚠ URLが不正', '#dc2626', 'URLの形式が正しくありません'); showMargin(false); return; }
      setStatus('⏳ 確認中…', '#6b7280', 'テンプレートを確認中…');
      try {
        google.script.run
          .withSuccessHandler(function (res) {
            if ((($('templateUrl') && $('templateUrl').value) || '').trim() !== val) return; // 古い結果は破棄
            if (res && res.ok) {
              setStatus('✓ 有効・' + res.slideCount + '枚', '#16a34a', '✓ 有効: ' + (res.name || 'テンプレート') + '（' + res.slideCount + 'スライド）');
              showMargin(true);
              _warmTemplateCacheBg(val, res); // ★[キャッシュ] 設定時・画面読込時に裏でキャッシュを用意する
            } else {
              setStatus('⚠ 開けません', '#dc2626', '⚠ ' + ((res && res.error) || 'このスライドを開けません'));
              showMargin(false);
            }
          })
          .withFailureHandler(function (err) {
            if ((($('templateUrl') && $('templateUrl').value) || '').trim() !== val) return;
            setStatus('⚠ 確認失敗', '#dc2626', '確認に失敗しました: ' + (err && err.message ? err.message : err));
            showMargin(false);
          })
          .checkTemplateUrl(val);
      } catch (e) { setStatus('⚠ 確認失敗', '#dc2626', '確認に失敗しました'); showMargin(false); }
    }

    // ★[キャッシュ] テンプレ設定時・画面読込時に、裏でテンプレートのキャッシュを用意する。
    //   サーバ側(warmTemplateCache)が「キャッシュの有無」「テンプレの更新時刻がキャッシュ作成後か」
    //   「キャッシュファイルが消えていないか」を確かめ、必要なときだけ作り直す(ゴミ箱内に置く)。
    //   ヒットなら数百msで返る。同じテンプレへの連打は60秒に1回に間引く。UIは止めない。
    // ★[テンプレ準備] 生成の直前に、テンプレのキャッシュを判定/準備してから本体の生成に入る。
    //   ヒットならGASの往復込みで1〜3秒で戻り、何も出さない。作り直し(40〜60秒)が要るとき＝4秒を超えたときだけ
    //   「テンプレートを準備中」のトーストを出し、時間がかかっている理由が分かるようにする（ユーザー要望 2026-09-04）。
    //   失敗しても生成は止めない（サーバ側の生成経路が同じ判定でその場で書き出す）。
    function _prepareTemplateBeforeGenerate(tplUrl) {
      return new Promise((resolve) => {
        let toastId = null, done = false;
        const timer = setTimeout(() => {
          if (done) return;
          try { toastId = showStatus('テンプレートを準備しています…（初回やテンプレート更新後だけ、1分ほどかかります）', 'info', 180000); } catch (e) {}
        }, 4000); // ★ ヒット時の往復(実測1〜3秒)では出さず、作り直し(実測40〜60秒)のときだけ出す
        const finish = (r) => {
          if (done) return; done = true; clearTimeout(timer);
          if (toastId) { const el = $(toastId); if (el) el.remove(); }
          try { const key = (String(tplUrl).match(/[-\w]{25,}/) || [tplUrl])[0]; __tplWarmLast[key] = Date.now(); } catch (e) {}
          if (r && r.status === 'rebuilt') console.log('[generate] テンプレートのキャッシュを準備しました:', r);
          resolve(r || null);
        };
        try {
          google.script.run.withSuccessHandler(finish).withFailureHandler(() => finish(null)).warmTemplateCache(tplUrl);
        } catch (e) { finish(null); }
      });
    }

    const __tplWarmLast = {};
    function _warmTemplateCacheBg(val, info) {
      try {
        const key = (String(val).match(/[-\w]{25,}/) || [val])[0];
        const now = Date.now();
        if (__tplWarmLast[key] && (now - __tplWarmLast[key]) < 60000) return;
        __tplWarmLast[key] = now;
        const statusEl = $('templateUrlStatus');
        const base = '✓ 有効・' + ((info && info.slideCount) || '?') + '枚';
        const setStatus = (text, color, full) => { if (statusEl) { statusEl.textContent = text || ''; statusEl.style.color = color || ''; statusEl.style.display = text ? '' : 'none'; statusEl.title = full || text || ''; } };
        const stillSame = () => ((($('templateUrl') && $('templateUrl').value) || '').trim() === val);
        const fmt = (ms) => { try { return ms ? new Date(ms).toLocaleString() : '不明'; } catch (e) { return '不明'; } };
        setStatus(base + '・⏳ キャッシュ確認中', '#6b7280', 'テンプレートのキャッシュを確認しています…');
        google.script.run
          .withSuccessHandler(function (r) {
            if (!stillSame()) return;
            if (r && r.status === 'hit') {
              setStatus(base + '・キャッシュ済', '#16a34a', 'キャッシュ済み（作成: ' + fmt(r.builtAt) + '）。生成は速い状態です');
            } else if (r && r.status === 'rebuilt') {
              setStatus(base + '・キャッシュ作成済', '#16a34a', 'テンプレートのキャッシュを作り直しました（理由: ' + (r.reason || '') + '）。次の生成から速くなります');
              // ★ 裏方の作成はトーストを出さない（こっそり処理する。ユーザー要望 2026-09-04）
            } else if (r && r.status === 'busy') {
              setStatus(base, '#16a34a', '別の処理がテンプレートのキャッシュを作成中です');
            } else {
              setStatus(base + '・キャッシュ未作成', '#d97706', 'キャッシュを用意できませんでした: ' + ((r && r.reason) || '不明') + '（生成はできます。初回だけ時間がかかります）');
            }
          })
          .withFailureHandler(function (err) {
            if (!stillSame()) return;
            setStatus(base, '#16a34a', 'キャッシュの確認に失敗しました（生成はできます）: ' + (err && err.message ? err.message : err));
          })
          .warmTemplateCache(val);
      } catch (e) { console.warn('[tplWarm]', e); }
    }

    function applyAllSettings(settings) {
      const s = settings || {};
      updatePrimaryColor(s.primaryColor || $('primaryColorText').value);
      updateLargeFontColor(s.largeFontColor || '#333333');
      updateSmallFontColor(s.smallFontColor || '#1F2937');
      updateBackgroundColor(s.backgroundColor || '#FFFFFF'); // updateFontColor を呼ぶ
      $('backgroundColor').value = s.backgroundColor || '#FFFFFF';
      $('backgroundColorText').value = s.backgroundColor || '#FFFFFF';
      $('graphColorTheme').value = s.graphColorTheme || 'primary';
      $('fontFamily').value = s.fontFamily || 'Noto Sans JP';
      $('footerText').value = (s.footerText === undefined) ? '© Google Inc.' : s.footerText;
      $('driveFolderUrl').value = s.driveFolderUrl || '';
      $('showTitleUnderline').checked = typeof s.showTitleUnderline === 'boolean' ? s.showTitleUnderline : true;
      $('showBottomBar').checked = typeof s.showBottomBar === 'boolean' ? s.showBottomBar : true;
      $('showDateColumn').checked = typeof s.showDateColumn === 'boolean' ? s.showDateColumn : true;
      $('showPageNumber').checked = typeof s.showPageNumber === 'boolean' ? s.showPageNumber : true;
      $('enableGradient').checked = typeof s.enableGradient === 'boolean' ? s.enableGradient : true;
      $('enableGraphAnimation').checked = typeof s.enableGraphAnimation === 'boolean' ? s.enableGraphAnimation : false;
      if ($('graphAnimFormatSelect')) $('graphAnimFormatSelect').value = s.graphAnimFormat || 'apng'; // ★[新規L] 形式選択を復元
      if ($('nativeSectionMode')) $('nativeSectionMode').checked = typeof s.nativeSectionMode === 'boolean' ? s.nativeSectionMode : true;
      if ($('nativeDecorMode')) $('nativeDecorMode').checked = typeof s.nativeDecorMode === 'boolean' ? s.nativeDecorMode : true;
      if ($('restTextMode')) $('restTextMode').checked = typeof s.restTextMode === 'boolean' ? s.restTextMode : true;
      if ($('nativeChartMode')) $('nativeChartMode').checked = typeof s.nativeChartMode === 'boolean' ? s.nativeChartMode : true;
      if ($('ghostTopAlpha')) $('ghostTopAlpha').value = (s.ghostTopAlpha != null && !isNaN(parseFloat(s.ghostTopAlpha))) ? parseFloat(s.ghostTopAlpha) : 0.35;
      if ($('ghostBottomAlpha')) $('ghostBottomAlpha').value = (s.ghostBottomAlpha != null && !isNaN(parseFloat(s.ghostBottomAlpha))) ? parseFloat(s.ghostBottomAlpha) : 0.2;
      if ($('useNotebookLmSplit')) $('useNotebookLmSplit').checked = typeof s.useNotebookLmSplit === 'boolean' ? s.useNotebookLmSplit : true;
      if ($('newVideoLogic')) $('newVideoLogic').checked = typeof s.newVideoLogic === 'boolean' ? s.newVideoLogic : true;
      if ($('animationFormat')) $('animationFormat').value = (s.animationFormat === 'gif') ? 'gif' : 'apng';
      $('gradientStart').value = s.gradientStart || '#4285F4';
      $('gradientStartText').value = s.gradientStart || '#4285F4';
      $('gradientEnd').value = s.gradientEnd || '#ff52df'; 
      $('gradientEndText').value = s.gradientEnd || '#ff52df';
      const defaultLogo = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Google_Gemini_logo.svg/1280px-Google_Gemini_logo.svg.png';
  $('headerLogoUrl').value = (s.headerLogoUrl === undefined) ? defaultLogo : s.headerLogoUrl;
    $('closingLogoUrl').value = (s.closingLogoUrl === undefined) ? defaultLogo : s.closingLogoUrl;
      $('titleBgUrl').value = s.titleBgUrl || '';
      $('sectionBgUrl').value = s.sectionBgUrl || '';
      $('mainBgUrl').value = s.mainBgUrl || '';
      $('closingBgUrl').value = s.closingBgUrl || '';
      if ($('templateUrl')) $('templateUrl').value = s.templateUrl || '';
      // ★[新2] テンプレURLの有効性チェック＆余白の表示制御を配線＋初期判定
      try { _wireTemplateUrlValidation(); validateTemplateUrl(); } catch (e) { console.warn('[tplValidate]', e); }
      if ($('marginTop')) $('marginTop').value = s.marginTop || '';
      if ($('marginBottom')) $('marginBottom').value = s.marginBottom || '';
      if ($('marginLeft')) $('marginLeft').value = s.marginLeft || '';
      if ($('marginRight')) $('marginRight').value = s.marginRight || '';
      toggleGradientOptions();
      toggleGraphAnimFormat();
      updateGradientPreview();
    }

    const PRESET_EXPORT_TYPE = 'majin-slide-settings-presets';
    const PRESET_EXPORT_VERSION = 1;
    const PRESET_MAX_COUNT = 10;
    const BOOLEAN_PRESET_KEYS = [
      'showTitleUnderline', 'showBottomBar', 'showDateColumn',
      'enableGradient', 'enableGraphAnimation', 'showPageNumber', 'nativeSectionMode', 'nativeDecorMode', 'restTextMode', 'nativeChartMode', 'useNotebookLmSplit', 'newVideoLogic'
    ];
    const COLOR_PRESET_KEYS = [
      'primaryColor', 'largeFontColor', 'smallFontColor',
      'backgroundColor', 'gradientStart', 'gradientEnd'
    ];
    const NUMBER_TEXT_PRESET_KEYS = ['marginTop', 'marginBottom', 'marginLeft', 'marginRight', 'ghostTopAlpha', 'ghostBottomAlpha'];
    const SETTING_PRESET_KEYS = [
      'primaryColor', 'largeFontColor', 'smallFontColor', 'backgroundColor',
      'graphColorTheme', 'fontFamily', 'showTitleUnderline', 'showBottomBar',
      'showDateColumn', 'enableGradient', 'gradientStart', 'gradientEnd',
      'enableGraphAnimation', 'graphAnimFormat', 'footerText', 'headerLogoUrl', 'closingLogoUrl',
      'titleBgUrl', 'sectionBgUrl', 'mainBgUrl', 'closingBgUrl',
      'templateUrl', 'marginTop', 'marginBottom', 'marginLeft', 'marginRight',
      'driveFolderUrl', 'showPageNumber', 'nativeSectionMode', 'nativeDecorMode', 'restTextMode', 'nativeChartMode', 'ghostTopAlpha', 'ghostBottomAlpha', 'useNotebookLmSplit', 'newVideoLogic',
      'animationFormat'
    ];

    function isPlainObject(value) {
      return value !== null && typeof value === 'object' && !Array.isArray(value);
    }

    function coercePresetBoolean(value) {
      if (typeof value === 'boolean') return value;
      if (typeof value === 'string') return value.toLowerCase() === 'true';
      return !!value;
    }

    function normalizePresetColor(value) {
      const color = String(value || '').trim();
      const shortMatch = color.match(/^#([0-9A-F])([0-9A-F])([0-9A-F])$/i);
      if (shortMatch) {
        return '#' + shortMatch.slice(1).map(ch => ch + ch).join('').toUpperCase();
      }
      return /^#[0-9A-F]{6}$/i.test(color) ? color.toUpperCase() : null;
    }

    function normalizePresetName(name) {
      const normalized = String(name || '').trim();
      if (!normalized || normalized === '_default') return '';
      return normalized.slice(0, 20);
    }

    function looksLikeSettingsObject(value) {
      return isPlainObject(value) && SETTING_PRESET_KEYS.some(key => Object.prototype.hasOwnProperty.call(value, key));
    }

    function sanitizeImportedSettings(settings) {
      if (!looksLikeSettingsObject(settings)) return null;
      const clean = {};

      SETTING_PRESET_KEYS.forEach(key => {
        if (settings[key] === undefined || settings[key] === null) return;

        if (BOOLEAN_PRESET_KEYS.includes(key)) {
          clean[key] = coercePresetBoolean(settings[key]);
          return;
        }

        if (COLOR_PRESET_KEYS.includes(key)) {
          const color = normalizePresetColor(settings[key]);
          if (color) clean[key] = color;
          return;
        }

        if (NUMBER_TEXT_PRESET_KEYS.includes(key)) {
          const rawValue = String(settings[key]).trim();
          if (rawValue === '') {
            clean[key] = '';
            return;
          }
          const numValue = Number(rawValue);
          clean[key] = Number.isFinite(numValue) ? String(Math.max(0, numValue)) : '';
          return;
        }

        if (key === 'graphColorTheme') {
          const theme = String(settings[key]).trim();
          clean[key] = ['original', 'primary', 'gemini'].includes(theme) ? theme : 'primary';
          return;
        }

        if (key === 'animationFormat') {
          clean[key] = (String(settings[key]).trim() === 'gif') ? 'gif' : 'apng';
          return;
        }

        clean[key] = String(settings[key]);
      });

      return Object.keys(clean).length > 0 ? clean : null;
    }

    function loadUserPresets() {
      try {
        const parsed = JSON.parse(localStorage.getItem('slideFactory_presets') || '{}');
        return isPlainObject(parsed) ? parsed : {};
      } catch (e) {
        console.warn('プリセットの読み込みに失敗しました:', e.message);
        return {};
      }
    }
    function saveUserPresets(presets) { localStorage.setItem('slideFactory_presets', JSON.stringify(presets)); }

    function updatePresetMenuState() {
      const deleteBtn = $('deletePreset');
      const exportBtn = $('exportPreset');
      const exportAllBtn = $('exportAllPresets');
      const select = $('presetSelect');
      if (!select) return;
      const isCustomPreset = select.value !== '_default' && select.value !== '';
      if (deleteBtn) deleteBtn.disabled = !isCustomPreset;
      if (exportBtn) exportBtn.disabled = !isCustomPreset;
      // ★ 保存済みプリセットが1件も無い（＝デフォルト設定のみ）場合は「エクスポート（全件）」もグレーアウト
      const hasUserPresets = Object.keys(loadUserPresets()).length > 0;
      if (exportAllBtn) exportAllBtn.disabled = !hasUserPresets;
    }

    function closePresetActionMenu() {
      const menu = $('presetActionMenu');
      const button = $('presetMenuButton');
      if (menu) menu.hidden = true;
      if (button) button.setAttribute('aria-expanded', 'false');
    }

    function togglePresetActionMenu(event) {
      if (event) event.stopPropagation();
      const menu = $('presetActionMenu');
      const button = $('presetMenuButton');
      if (!menu || !button) return;

      updatePresetMenuState();
      const shouldOpen = menu.hidden;
      menu.hidden = !shouldOpen;
      button.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
    }

    function formatPresetExportTimestamp(date) {
      const pad = value => String(value).padStart(2, '0');
      return [
        date.getFullYear(),
        pad(date.getMonth() + 1),
        pad(date.getDate())
      ].join('') + '-' + [
        pad(date.getHours()),
        pad(date.getMinutes()),
        pad(date.getSeconds())
      ].join('');
    }

    function downloadPresetJson(presets, fileNamePrefix, successMessage) {
      const payload = {
        type: PRESET_EXPORT_TYPE,
        version: PRESET_EXPORT_VERSION,
        exportedAt: new Date().toISOString(),
        presets: presets
      };
      const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = `${fileNamePrefix}_${formatPresetExportTimestamp(new Date())}.json`;
      document.body.appendChild(link);
      link.click();
      link.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
      showStatus(successMessage, 'success');
    }

    function getPresetCount(presets) {
      return Object.keys(presets || {}).length;
    }

    function createUniquePresetName(baseName, presets) {
      const base = normalizePresetName(baseName || 'インポート設定') || 'インポート設定';
      const suffixBase = ' コピー';
      let candidate = normalizePresetName(base + suffixBase);
      if (candidate && !presets[candidate]) return candidate;

      for (let i = 2; i <= 99; i++) {
        const suffix = ` コピー${i}`;
        const trimmedBase = base.slice(0, Math.max(1, 20 - suffix.length));
        candidate = normalizePresetName(trimmedBase + suffix);
        if (candidate && !presets[candidate]) return candidate;
      }

      return '';
    }

    function exportSelectedPreset() {
      const presets = loadUserPresets();
      const select = $('presetSelect');
      const presetName = select ? select.value : '';

      if (!presetName || presetName === '_default' || !presets[presetName]) {
        showStatus('エクスポートするプリセットを選択してください', 'error');
        return;
      }

      const selectedPreset = {};
      selectedPreset[presetName] = presets[presetName];
      downloadPresetJson(selectedPreset, 'majin-slide-preset', `プリセット「${presetName}」をエクスポートしました`);
    }

    function exportAllUserPresets() {
      const presets = loadUserPresets();
      const names = Object.keys(presets);

      if (names.length === 0) {
        showStatus('エクスポートするプリセットがありません', 'error');
        return;
      }

      downloadPresetJson(presets, 'majin-slide-presets', `プリセット${names.length}件をエクスポートしました`);
    }

    function extractImportedPresets(data) {
      let source = null;

      if (isPlainObject(data) && isPlainObject(data.presets)) {
        source = data.presets;
      } else if (isPlainObject(data) && isPlainObject(data.settings)) {
        const name = normalizePresetName(data.name || 'インポート設定');
        source = {};
        source[name] = data.settings;
      } else if (looksLikeSettingsObject(data)) {
        source = { 'インポート設定': data };
      } else if (isPlainObject(data)) {
        source = data;
      }

      if (!isPlainObject(source)) {
        throw new Error('プリセット形式のJSONではありません。');
      }

      const imported = {};
      Object.keys(source).forEach(originalName => {
        const name = normalizePresetName(originalName);
        const rawValue = source[originalName];
        const settings = looksLikeSettingsObject(rawValue) ? rawValue : (isPlainObject(rawValue) ? rawValue.settings : null);
        const cleanSettings = sanitizeImportedSettings(settings);

        if (name && cleanSettings) {
          imported[name] = cleanSettings;
        }
      });

      if (Object.keys(imported).length === 0) {
        throw new Error('有効なプリセットが見つかりませんでした。');
      }

      return imported;
    }

    function showPresetImportConflictModal(presetName, currentPresets) {
      return new Promise(resolve => {
        const modal = $('presetImportConflictModal');
        const nameEl = $('presetConflictName');
        const renameArea = $('presetConflictRenameArea');
        const nameInput = $('presetConflictNewName');
        const errorEl = $('presetConflictError');
        const overwriteBtn = $('presetConflictOverwriteBtn');
        const renameBtn = $('presetConflictRenameBtn');
        const skipBtn = $('presetConflictSkipBtn');

        if (!modal || !nameEl || !renameArea || !nameInput || !errorEl || !overwriteBtn || !renameBtn || !skipBtn) {
          resolve({ action: 'overwrite' });
          return;
        }

        let isRenameMode = false;
        nameEl.textContent = `「${presetName}」`;
        nameInput.value = createUniquePresetName(presetName, currentPresets);
        errorEl.textContent = '';
        renameArea.style.display = 'none';
        overwriteBtn.style.display = '';
        renameBtn.textContent = '別名で保存';

        const finish = result => {
          modal.style.display = 'none';
          overwriteBtn.onclick = null;
          renameBtn.onclick = null;
          skipBtn.onclick = null;
          resolve(result);
        };

        overwriteBtn.onclick = () => finish({ action: 'overwrite' });
        skipBtn.onclick = () => finish({ action: 'skip' });
        renameBtn.onclick = () => {
          if (!isRenameMode) {
            isRenameMode = true;
            renameArea.style.display = 'block';
            overwriteBtn.style.display = 'none';
            renameBtn.textContent = 'この名前で保存';
            errorEl.textContent = '';
            setTimeout(() => {
              nameInput.focus();
              nameInput.select();
            }, 0);
            return;
          }

          const rawName = nameInput.value.trim();

          if (!rawName) {
            errorEl.textContent = '別名を入力してください。';
            return;
          }

          if (rawName === '_default') {
            errorEl.textContent = '「_default」はプリセット名として使用できません。';
            return;
          }

          if (rawName.length > 20) {
            errorEl.textContent = 'プリセット名は20文字以内にしてください。';
            return;
          }

          if (rawName === presetName || currentPresets[rawName]) {
            errorEl.textContent = 'この名前のプリセットは既に存在します。別の名前を入力してください。';
            return;
          }

          if (getPresetCount(currentPresets) >= PRESET_MAX_COUNT) {
            errorEl.textContent = 'プリセット数が上限に達しているため、別名保存できません。上書きまたはスキップしてください。';
            return;
          }

          finish({ action: 'rename', name: rawName });
        };

        modal.style.display = 'flex';
      });
    }

    async function importUserPresets(importedPresets) {
      const currentPresets = loadUserPresets();
      const importNames = Object.keys(importedPresets);

      if (importNames.length === 0) {
        showStatus('読み込む新規プリセットはありませんでした', 'info');
        return;
      }

      let addedCount = 0;
      let overwrittenCount = 0;
      let renamedCount = 0;
      let userSkippedCount = 0;
      let limitSkippedCount = 0;
      let firstImportedName = null;

      for (const name of importNames) {
        const exists = !!currentPresets[name];

        if (exists) {
          const result = await showPresetImportConflictModal(name, currentPresets);

          if (!result || result.action === 'skip') {
            userSkippedCount++;
            continue;
          }

          if (result.action === 'rename') {
            const newName = normalizePresetName(result.name);
            if (!newName || currentPresets[newName] || getPresetCount(currentPresets) >= PRESET_MAX_COUNT) {
              limitSkippedCount++;
              continue;
            }
            currentPresets[newName] = importedPresets[name];
            renamedCount++;
            if (!firstImportedName) firstImportedName = newName;
            continue;
          }

          currentPresets[name] = importedPresets[name];
          overwrittenCount++;
          if (!firstImportedName) firstImportedName = name;
          continue;
        }

        if (getPresetCount(currentPresets) >= PRESET_MAX_COUNT) {
          limitSkippedCount++;
          continue;
        }

        currentPresets[name] = importedPresets[name];
        addedCount++;
        if (!firstImportedName) firstImportedName = name;
      }

      const skippedCount = userSkippedCount + limitSkippedCount;

      if (addedCount === 0 && overwrittenCount === 0 && renamedCount === 0) {
        const message = userSkippedCount > 0
          ? '読み込むプリセットはありませんでした'
          : 'プリセット数が上限に達しているため読み込めませんでした';
        showStatus(message, userSkippedCount > 0 ? 'info' : 'error', 6000);
        return;
      }

      saveUserPresets(currentPresets);
      updatePresetSelect();

      if (firstImportedName) {
        $('presetSelect').value = firstImportedName;
        updatePresetMenuState();
        applyAllSettings(currentPresets[firstImportedName]);
        try { localStorage.setItem('slideFactory_lastSelectedPreset', firstImportedName); } catch (e) {}
      }

      let message = `プリセットを読み込みました（追加${addedCount}件`;
      if (overwrittenCount > 0) message += `、上書き${overwrittenCount}件`;
      if (renamedCount > 0) message += `、別名保存${renamedCount}件`;
      if (skippedCount > 0) message += `、スキップ${skippedCount}件`;
      message += '）';
      showStatus(message, skippedCount > 0 ? 'info' : 'success', 6000);
    }

    function openPresetImportDialog() {
      const input = $('presetImportFile');
      input.value = '';
      input.click();
    }

    function handlePresetImportFileChange(event) {
      const file = event.target.files && event.target.files[0];
      if (!file) return;

      const reader = new FileReader();
      reader.onload = async function() {
        try {
          const parsed = JSON.parse(String(reader.result || ''));
          const importedPresets = extractImportedPresets(parsed);
          await importUserPresets(importedPresets);
        } catch (e) {
          console.error('プリセットインポートエラー:', e);
          showStatus(`プリセットのインポートに失敗しました: ${e.message}`, 'error', 6000);
        } finally {
          event.target.value = '';
        }
      };
      reader.onerror = function() {
        showStatus('プリセットファイルの読み込みに失敗しました', 'error', 6000);
        event.target.value = '';
      };
      reader.readAsText(file);
    }

    /* =========================================================================
     *  新5: 管理者メニュー（Gem設定 / 設定プリセットの共有 / 管理者の設定）
     * ========================================================================= */
    var __adminInfo = { email: '', isAdmin: false, adminEmails: [], noAdmins: true, canManage: true };
    var __sharedPresets = {};
    var __adminEmailsDraft = [];
    var SHARED_PRESET_PREFIX = '__shared:';

    // ★[新規C] メニュー表示可否: 管理者が0人なら全員に表示(=最初の管理者を登録可能)、1人でも居れば管理者のみ
    function _canManageAdmin() {
      const admins = (__adminInfo && __adminInfo.adminEmails) || [];
      if (typeof __adminInfo.canManage === 'boolean') return __adminInfo.canManage;
      return admins.length === 0 || !!__adminInfo.isAdmin;
    }
    function _updateAdminMenuVisibility() {
      const wrap = $('adminMenuWrap');
      const can = _canManageAdmin();
      if (wrap) wrap.style.display = can ? '' : 'none';
      // ★[新規D] 権限を失った場合は body直下に開いたままのメニューを閉じる
      if (!can) { try { closeAdminMenu(); } catch (e) {} }
    }

    // 管理者情報＋共有プリセットをサーバーから取得し、UIへ反映
    function refreshAdminInfo() {
      try {
        google.script.run
          .withSuccessHandler(function (info) {
            __adminInfo = info || { email: '', isAdmin: false, adminEmails: [], noAdmins: true, canManage: true };
            _updateAdminMenuVisibility();
          })
          .withFailureHandler(function () {})
          .getAdminInfo();
      } catch (e) {}
      // 共有プリセットは全ユーザーが取得して一覧へ注入する
      try {
        google.script.run
          .withSuccessHandler(function (shared) {
            __sharedPresets = (shared && typeof shared === 'object') ? shared : {};
            try {
              const sel = $('presetSelect');
              const keep = sel ? sel.value : null;
              updatePresetSelect();
              if (sel && keep) { sel.value = keep; if (sel.value !== keep) sel.value = '_default'; }
            } catch (e2) {}
          })
          .withFailureHandler(function () {})
          .getSharedPresets();
      } catch (e) {}
    }

    function closeAdminMenu() {
      const menu = $('adminMenu');
      const btn = $('adminMenuButton');
      if (menu) menu.hidden = true;
      if (btn) btn.setAttribute('aria-expanded', 'false');
    }
    function toggleAdminMenu(event) {
      if (event) event.stopPropagation();
      const menu = $('adminMenu');
      const btn = $('adminMenuButton');
      if (!menu || !btn) return;
      const shouldOpen = menu.hidden;
      if (shouldOpen) {
        // ★[新規D] カードの overflow:hidden に切られないよう body直下・position:fixed でボタン位置に合わせて表示(見切れ解消)
        menu.style.position = 'fixed';
        menu.style.margin = '0';
        menu.style.right = 'auto';
        // 正確な寸法計測のため visibility:hidden のまま一旦表示してから配置(チラつき防止)
        menu.style.visibility = 'hidden';
        menu.hidden = false;
        const r = btn.getBoundingClientRect();
        const mw = menu.offsetWidth || 200;
        const mh = menu.offsetHeight || 150;
        const vw = window.innerWidth || 9999;
        const vh = window.innerHeight || 9999;
        let left = r.right - mw;
        const maxLeft = vw - mw - 8;
        if (left > maxLeft) left = maxLeft;
        if (left < 8) left = 8;
        let top = r.bottom + 4;
        const maxTop = vh - mh - 8;
        if (top > maxTop) top = Math.max(8, r.top - mh - 4); // 下に収まらなければボタン上へフリップ
        menu.style.left = left + 'px';
        menu.style.top = top + 'px';
        menu.style.visibility = 'visible';
        btn.setAttribute('aria-expanded', 'true');
      } else {
        menu.hidden = true;
        btn.setAttribute('aria-expanded', 'false');
      }
    }
    function handleAdminMenuAction(action) {
      closeAdminMenu();
      if (action === 'gem') openGemSettingsModal();
      else if (action === 'share') openPresetShareModal();
      else if (action === 'admins') openAdminSettingsModal();
    }

    // --- Gem設定モーダル: プロンプトのコピー処理（初回設定フロー / 管理メニューの「Gemの設定」で共通利用） ---
    function copyGemPromptText() {
      const ta = $('gemPromptTextarea');
      if (!ta) return;
      const text = ta.value || '';
      if (!text || text === '読み込み中...') return;
      const onOk = function () { try { showStatus('プロンプトをコピーしました', 'success'); } catch (e) {} };
      const fallback = function () {
        try {
          const wasReadonly = ta.hasAttribute('readonly');
          if (wasReadonly) ta.removeAttribute('readonly');
          ta.focus();
          ta.select();
          try { ta.setSelectionRange(0, text.length); } catch (e) {}
          const ok = document.execCommand('copy');
          if (wasReadonly) ta.setAttribute('readonly', '');
          try { if (window.getSelection) window.getSelection().removeAllRanges(); } catch (e) {}
          if (ok) onOk();
          else alert('コピーに失敗しました。テキストを選択して手動でコピーしてください。');
        } catch (e) {
          alert('コピーに失敗しました。テキストを選択して手動でコピーしてください。');
        }
      };
      // navigator.clipboard 自体が存在しない（sandboxed iframe 等）と writeText 評価時に同期例外→
      // 旧コードは .catch に入れずフォールバックも走らず「無反応」になっていたため、先に存在判定する
      try {
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(text).then(onOk).catch(fallback);
        } else {
          fallback();
        }
      } catch (e) {
        fallback();
      }
    }

    // コピーボタンにクリックハンドラを（重複登録を避けて）バインドする
    function bindGemPromptCopyButton() {
      const btn = $('copyGemPromptBtn');
      if (!btn) return;
      const fresh = btn.cloneNode(true);
      btn.parentNode.replaceChild(fresh, btn);
      fresh.addEventListener('click', copyGemPromptText);
    }

    // --- [起動時チェック] Google Slides API が未有効ならモーダルで案内する（毎回表示・抑制なし） ---
    //   検出はサーバ checkSlidesApiAvailable()(REST 403 SERVICE_DISABLED 判定)。@113でSlidesはRESTシムのため
    //   typeof では判定不可。未有効でも図は画像として貼り付くフォールバックで動くので、案内のみ(任意)。
    function _showSlidesApiModalWhenFree(tries) {
      tries = tries || 0;
      var anyOpen = false;
      var modals = document.querySelectorAll('.modal-overlay');
      for (var i = 0; i < modals.length; i++) {
        var o = modals[i];
        if (o.id !== 'slidesApiModal' && o.style.display && o.style.display !== 'none') { anyOpen = true; break; }
      }
      // 他モーダル(初回Gem設定等)が開いている間は重ねない。空くまで最大~2分リトライ。
      if (anyOpen && tries < 60) { setTimeout(function () { _showSlidesApiModalWhenFree(tries + 1); }, 2000); return; }
      _confirmThenShowSlidesApiModal(1);
    }
    function _openSlidesApiModal() {
      var m = document.getElementById('slidesApiModal');
      if (!m) return;
      var cb = document.getElementById('slidesApiModalCloseBtn');
      if (cb) cb.onclick = function () { m.style.display = 'none'; };
      m.style.display = 'flex';
    }
    // 出す直前に、もう一度サーバへ確かめる。
    //   起動時の判定から実際に出すまでは最大2分ずれる(上の待ち合わせのぶん)。
    //   さらに「サービス」に Slides API を足した直後は Google 側の反映に時間がかかり、
    //   足したのに未有効と判定されることがある(実測)。古い判定のまま警告を出さない。
    //   まだ未有効なら30秒おいてもう一度だけ確かめ、それでも未有効のときに出す。
    function _confirmThenShowSlidesApiModal(retry) {
      try {
        google.script.run
          .withSuccessHandler(function (r) {
            var disabled = r && r.available === false && r.reason === 'SERVICE_DISABLED';
            if (!disabled) return;                       // 反映済み。案内は出さない
            if (retry > 0) { setTimeout(function () { _confirmThenShowSlidesApiModal(retry - 1); }, 30000); return; }
            _openSlidesApiModal();
          })
          .withFailureHandler(function () { /* 判定できないときは出さない(誤警告回避) */ })
          .checkSlidesApiAvailable();
      } catch (e) {}
    }
    function checkSlidesApiAndWarn() {
      try {
        google.script.run
          .withSuccessHandler(function (r) {
            if (r && r.available === false && r.reason === 'SERVICE_DISABLED') _showSlidesApiModalWhenFree(0);
          })
          .withFailureHandler(function () { /* 判定失敗時は何もしない(誤警告回避) */ })
          .checkSlidesApiAvailable();
      } catch (e) {}
    }
    // 画面表示後にバックグラウンドで1回チェック(初期ロードと競合しないよう遅延)。
    document.addEventListener('DOMContentLoaded', function () { setTimeout(checkSlidesApiAndWarn, 2500); });

    // --- Gemの設定（既存の初回設定モーダルを再利用。キャンセル可・現在値プリフィル） ---
    function openGemSettingsModal() {
      const modal = $('gemUrlModal');
      let input = $('gemUrlInput');
      let saveBtn = $('saveGemUrlBtn');
      const errorEl = $('gemUrlError');
      const cancelBtn = $('cancelGemUrlBtn');
      if (!modal || !input || !saveBtn) return;
      // 初回設定フローのリスナーと二重登録しないよう、ノードを差し替えてリスナーを除去
      const ni = input.cloneNode(true); input.parentNode.replaceChild(ni, input); input = ni;
      const nb = saveBtn.cloneNode(true); saveBtn.parentNode.replaceChild(nb, saveBtn); saveBtn = nb;
      input.value = (loadedSettings && loadedSettings.geminiGemUrl) ? loadedSettings.geminiGemUrl : '';
      if (errorEl) errorEl.style.display = 'none';
      saveBtn.textContent = '保存';
      saveBtn.disabled = false;
      try {
        const pd = $('gem-prompt-data');
        const pt = $('gemPromptTextarea');
        if (pt && pd) pt.value = pd.textContent.trim() || '';
      } catch (e) {}
      bindGemPromptCopyButton(); // [修正] 管理メニュー経由でもコピーボタンを有効化（従来はハンドラ未登録で無反応だった）
      saveBtn.addEventListener('click', function () {
        const url = (input.value || '').trim();
        if (errorEl) errorEl.style.display = 'none';
        saveBtn.disabled = true; saveBtn.textContent = '保存中...';
        google.script.run
          .withSuccessHandler(function (response) {
            saveBtn.disabled = false; saveBtn.textContent = '保存';
            if (response && (response.status === 'success' || response.status === 'warning')) {
              loadedSettings.geminiGemUrl = url;
              const gb = $('geminiGemButton'); if (gb) gb.href = url;
              modal.style.display = 'none';
              showStatus(response.status === 'warning' ? 'Gem URLを保存しました（警告あり）' : 'Gem URLを保存しました', response.status === 'warning' ? 'info' : 'success');
              try { refreshAdminInfo(); } catch (e) {}
            } else if (errorEl) {
              errorEl.textContent = (response && response.message) || '保存に失敗しました';
              errorEl.style.color = 'var(--danger-color)';
              errorEl.style.display = 'block';
            }
          })
          .withFailureHandler(function (err) {
            saveBtn.disabled = false; saveBtn.textContent = '保存';
            if (errorEl) { errorEl.textContent = 'サーバーエラー: ' + err.message; errorEl.style.display = 'block'; }
          })
          .saveGemUrl(url);
      });
      if (cancelBtn) {
        cancelBtn.style.display = 'block';
        const nc = cancelBtn.cloneNode(true); cancelBtn.parentNode.replaceChild(nc, cancelBtn);
        nc.addEventListener('click', function () { modal.style.display = 'none'; });
      }
      modal.style.display = 'flex';
    }

    // --- 管理者の設定 ---
    function openAdminSettingsModal() {
      __adminEmailsDraft = (__adminInfo.adminEmails || []).slice();
      renderAdminEmailList();
      const err = $('adminSettingsError'); if (err) err.style.display = 'none';
      const ni = $('adminEmailNewInput');
      // ★[新規C] 管理者が未登録の状態では、自分のメールを入力欄にプリフィルして最初の管理者登録を促す
      if (ni) ni.value = (!__adminEmailsDraft.length && __adminInfo.email) ? __adminInfo.email : '';
      $('adminSettingsModal').style.display = 'flex';
    }
    function renderAdminEmailList() {
      const box = $('adminEmailList');
      if (!box) return;
      box.innerHTML = '';
      if (!__adminEmailsDraft.length) {
        const p = document.createElement('p');
        p.textContent = '管理者が登録されていません。';
        p.style.cssText = 'color:#6b7280;font-size:0.875rem;margin:0;';
        box.appendChild(p);
      }
      __adminEmailsDraft.forEach(function (email, idx) {
        const row = document.createElement('div');
        row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:0.4rem 0.6rem;';
        const span = document.createElement('span');
        span.textContent = email;
        span.style.cssText = 'flex:1;font-size:0.875rem;word-break:break-all;';
        const isSelf = email === (__adminInfo.email || '');
        if (isSelf) {
          const tag = document.createElement('span');
          tag.textContent = '（自分）';
          tag.style.cssText = 'color:#6b7280;font-size:0.75rem;';
          span.appendChild(tag);
        }
        const del = document.createElement('button');
        del.type = 'button';
        del.textContent = '削除';
        del.className = 'btn-preset';
        del.style.cssText = 'flex-shrink:0;padding:0.2rem 0.6rem;font-size:0.8rem;';
        del.addEventListener('click', function () {
          __adminEmailsDraft.splice(idx, 1);
          renderAdminEmailList();
        });
        row.appendChild(span);
        row.appendChild(del);
        box.appendChild(row);
      });
    }
    function addAdminEmailDraft() {
      const inp = $('adminEmailNewInput');
      const err = $('adminSettingsError');
      if (!inp) return;
      const v = (inp.value || '').trim().toLowerCase();
      if (err) err.style.display = 'none';
      if (!v || v.indexOf('@') <= 0) {
        if (err) { err.textContent = '正しいメールアドレスを入力してください。'; err.style.display = 'block'; }
        return;
      }
      if (__adminEmailsDraft.indexOf(v) >= 0) {
        if (err) { err.textContent = 'すでに登録されています。'; err.style.display = 'block'; }
        return;
      }
      __adminEmailsDraft.push(v);
      inp.value = '';
      renderAdminEmailList();
    }
    function saveAdminSettings() {
      const err = $('adminSettingsError');
      const saveBtn = $('adminSettingsSaveBtn');
      if (err) err.style.display = 'none';
      if (!__adminEmailsDraft.length) {
        if (err) { err.textContent = '管理者を1人以上登録してください。'; err.style.display = 'block'; }
        return;
      }
      if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '保存中...'; }
      google.script.run
        .withSuccessHandler(function (res) {
          if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '保存'; }
          if (res && res.ok) {
            __adminInfo.adminEmails = res.adminEmails || __adminEmailsDraft;
            __adminInfo.isAdmin = (__adminInfo.adminEmails.indexOf(__adminInfo.email) >= 0);
            __adminInfo.noAdmins = (__adminInfo.adminEmails.length === 0);
            __adminInfo.canManage = (__adminInfo.noAdmins || __adminInfo.isAdmin);
            // ★[新規C] 保存後にメニュー表示可否を再計算(管理者を登録すると非管理者は以後メニュー非表示)
            _updateAdminMenuVisibility();
            $('adminSettingsModal').style.display = 'none';
            showStatus('管理者設定を保存しました', 'success');
          } else if (err) {
            err.textContent = (res && res.error) || '保存に失敗しました'; err.style.display = 'block';
          }
        })
        .withFailureHandler(function (e) {
          if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '保存'; }
          if (err) { err.textContent = 'サーバーエラー: ' + e.message; err.style.display = 'block'; }
        })
        .setAdminEmails(__adminEmailsDraft);
    }

    // --- 設定プリセットの共有 ---
    function openPresetShareModal() {
      const err = $('presetShareError'); if (err) err.style.display = 'none';
      renderPresetShareLists();
      $('presetShareModal').style.display = 'flex';
    }
    function renderPresetShareLists() {
      const ownBox = $('presetShareOwnList');
      const sharedBox = $('presetShareSharedList');
      if (ownBox) {
        ownBox.innerHTML = '';
        const presets = loadUserPresets();
        const names = Object.keys(presets);
        if (!names.length) {
          const p = document.createElement('p');
          p.textContent = '共有できるプリセットがありません（デフォルト以外を保存してください）。';
          p.style.cssText = 'color:#6b7280;font-size:0.875rem;margin:0;';
          ownBox.appendChild(p);
        }
        names.forEach(function (name) {
          const isShared = Object.prototype.hasOwnProperty.call(__sharedPresets, name);
          const row = document.createElement('div');
          row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:0.4rem 0.6rem;';
          const span = document.createElement('span');
          span.textContent = name;
          span.style.cssText = 'flex:1;font-size:0.875rem;word-break:break-all;';
          if (isShared) {
            const tag = document.createElement('span');
            tag.textContent = '（共有中）';
            tag.style.cssText = 'color:#16a34a;font-size:0.75rem;';
            span.appendChild(tag);
          }
          const btn = document.createElement('button');
          btn.type = 'button';
          btn.className = 'btn-preset';
          btn.style.cssText = 'flex-shrink:0;padding:0.2rem 0.7rem;font-size:0.8rem;';
          btn.textContent = isShared ? '共有解除' : '共有する';
          btn.addEventListener('click', function () {
            if (isShared) doUnsharePreset(name);
            else doSharePreset(name, presets[name]);
          });
          row.appendChild(span);
          row.appendChild(btn);
          ownBox.appendChild(row);
        });
      }
      if (sharedBox) {
        sharedBox.innerHTML = '';
        const sharedNames = Object.keys(__sharedPresets);
        if (!sharedNames.length) {
          const p = document.createElement('p');
          p.textContent = '現在共有中のプリセットはありません。';
          p.style.cssText = 'color:#6b7280;font-size:0.875rem;margin:0;';
          sharedBox.appendChild(p);
        }
        sharedNames.forEach(function (name) {
          const info = __sharedPresets[name] || {};
          const row = document.createElement('div');
          row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;padding:0.4rem 0.6rem;';
          const span = document.createElement('span');
          span.style.cssText = 'flex:1;font-size:0.875rem;word-break:break-all;';
          span.textContent = name;
          if (info.owner) {
            const tag = document.createElement('span');
            tag.textContent = '（共有者: ' + info.owner + '）';
            tag.style.cssText = 'color:#6b7280;font-size:0.72rem;display:block;';
            span.appendChild(tag);
          }
          const btn = document.createElement('button');
          btn.type = 'button';
          btn.className = 'btn-preset';
          btn.style.cssText = 'flex-shrink:0;padding:0.2rem 0.7rem;font-size:0.8rem;';
          btn.textContent = '共有解除';
          btn.addEventListener('click', function () { doUnsharePreset(name); });
          row.appendChild(span);
          row.appendChild(btn);
          sharedBox.appendChild(row);
        });
      }
    }
    function doSharePreset(name, settings) {
      const err = $('presetShareError'); if (err) err.style.display = 'none';
      google.script.run
        .withSuccessHandler(function (res) {
          if (res && res.ok) {
            __sharedPresets = res.sharedPresets || __sharedPresets;
            renderPresetShareLists();
            try { const sel = $('presetSelect'); const keep = sel ? sel.value : null; updatePresetSelect(); if (sel && keep) sel.value = keep; } catch (e) {}
            showStatus('プリセット「' + name + '」を共有しました', 'success');
          } else if (err) { err.textContent = (res && res.error) || '共有に失敗しました'; err.style.display = 'block'; }
        })
        .withFailureHandler(function (e) { if (err) { err.textContent = 'サーバーエラー: ' + e.message; err.style.display = 'block'; } })
        .sharePreset(name, settings);
    }
    function doUnsharePreset(name) {
      const err = $('presetShareError'); if (err) err.style.display = 'none';
      google.script.run
        .withSuccessHandler(function (res) {
          if (res && res.ok) {
            __sharedPresets = res.sharedPresets || {};
            renderPresetShareLists();
            try { const sel = $('presetSelect'); const keep = sel ? sel.value : null; updatePresetSelect(); if (sel && keep) { sel.value = keep; if (sel.value !== keep) sel.value = '_default'; } } catch (e) {}
            showStatus('プリセット「' + name + '」の共有を解除しました', 'success');
          } else if (err) { err.textContent = (res && res.error) || '解除に失敗しました'; err.style.display = 'block'; }
        })
        .withFailureHandler(function (e) { if (err) { err.textContent = 'サーバーエラー: ' + e.message; err.style.display = 'block'; } })
        .unsharePreset(name);
    }

    // 管理者メニュー・モーダルのイベント配線（初期化時に1回）
    function wireAdminMenu() {
      const btn = $('adminMenuButton');
      if (btn && !btn.dataset.wired) {
        btn.dataset.wired = '1';
        btn.addEventListener('click', toggleAdminMenu);
        document.addEventListener('click', function () { closeAdminMenu(); });
        const menu = $('adminMenu');
        if (menu) {
          // ★[新規D] ドロップダウンをbody直下へ移し、カードの overflow:hidden による見切れを回避
          try { if (menu.parentNode !== document.body) document.body.appendChild(menu); } catch (e) {}
          menu.style.position = 'fixed';
          menu.addEventListener('click', function (e) {
            const item = e.target.closest ? e.target.closest('.admin-menu-item') : null;
            if (item) { e.stopPropagation(); handleAdminMenuAction(item.getAttribute('data-action')); }
          });
          // 開いている間にスクロール/リサイズしたら閉じる(固定配置が浮いて見えるのを防止)
          window.addEventListener('scroll', function () { if (!menu.hidden) closeAdminMenu(); }, true);
          window.addEventListener('resize', function () { if (!menu.hidden) closeAdminMenu(); });
        }
      }
      const addBtn = $('adminEmailAddBtn');
      if (addBtn && !addBtn.dataset.wired) { addBtn.dataset.wired = '1'; addBtn.addEventListener('click', addAdminEmailDraft); }
      const saveBtn = $('adminSettingsSaveBtn');
      if (saveBtn && !saveBtn.dataset.wired) { saveBtn.dataset.wired = '1'; saveBtn.addEventListener('click', saveAdminSettings); }
      const cancelBtn = $('adminSettingsCancelBtn');
      if (cancelBtn && !cancelBtn.dataset.wired) { cancelBtn.dataset.wired = '1'; cancelBtn.addEventListener('click', function () { $('adminSettingsModal').style.display = 'none'; }); }
      const newInput = $('adminEmailNewInput');
      if (newInput && !newInput.dataset.wired) { newInput.dataset.wired = '1'; newInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); addAdminEmailDraft(); } }); }
      const shareClose = $('presetShareCloseBtn');
      if (shareClose && !shareClose.dataset.wired) { shareClose.dataset.wired = '1'; shareClose.addEventListener('click', function () { $('presetShareModal').style.display = 'none'; }); }
    }

    function updatePresetSelect() {
      const presets = loadUserPresets(); const select = $('presetSelect');

      select.innerHTML = '<option value="_default">デフォルト設定</option>';

      Object.keys(presets).forEach(name => {
        const option = document.createElement('option');
        option.value = name; option.textContent = name; select.appendChild(option);
      });
      // ★共有プリセット（管理者が全ユーザーへ共有したもの）。optgroup「共有プリセット」で区別済みのため名称に（共有）は付けない[新規G]
      try {
        const sharedNames = Object.keys(__sharedPresets || {});
        if (sharedNames.length) {
          const sharedGroup = document.createElement('optgroup');
          sharedGroup.label = '共有プリセット';
          sharedNames.forEach(name => {
            // 同名のローカルプリセットがある場合も、共有版は別 value で区別する
            const opt = document.createElement('option');
            opt.value = SHARED_PRESET_PREFIX + name;
            opt.textContent = name;
            sharedGroup.appendChild(opt);
          });
          select.appendChild(sharedGroup);
        }
      } catch (e) {}
      // ★カラーテーマを末尾の optgroup として追加（選択すると配色のみ適用。設定プリセットには残さない）
      //   現在は SHOW_THEME_PRESETS_IN_DROPDOWN=false のためUI非表示（機能・データは温存）。
      if (SHOW_THEME_PRESETS_IN_DROPDOWN && typeof THEME_PRESETS === 'object' && THEME_PRESETS) {
        const themeGroup = document.createElement('optgroup');
        themeGroup.label = 'カラーテーマ';
        Object.keys(THEME_PRESETS).forEach(key => {
          const opt = document.createElement('option');
          opt.value = THEME_PRESET_OPTION_PREFIX + key;
          opt.textContent = THEME_PRESETS[key].label;
          themeGroup.appendChild(opt);
        });
        select.appendChild(themeGroup);
      }
      updatePresetMenuState();
    }

    function saveCurrentPreset() {
      const currentPresetName = $('presetSelect').value;
      const modal = $('presetSaveModal');
      const overwriteBtn = $('overwritePresetBtn');
      const saveAsNewBtn = $('saveAsNewPresetBtn');
      const optionsDiv = $('presetSaveOptions');
      const newNameDiv = $('newPresetNameInput');
      const nameField = $('presetNameField');
      
      // モーダルを表示
      modal.style.display = 'flex';
      optionsDiv.style.display = 'flex';
      newNameDiv.style.display = 'none';
      nameField.value = '';
      
      // 既存のプリセットが選択されている場合は上書きボタンを表示
      if (currentPresetName && currentPresetName !== '_default') {
        overwriteBtn.style.display = 'block';
        $('currentPresetName').textContent = `「${currentPresetName}」`;
      } else {
        overwriteBtn.style.display = 'none';
      }
    }
    
    function performOverwriteSave() {
      const presetName = $('presetSelect').value;
      if (!presetName) return;

      if (presetName === '_default') {
        alert('「デフォルト設定」は上書き保存できません。別の名前で保存してください。');
        closePresetModal(); // モーダルを閉じる
        return;
      }
      
      const presets = loadUserPresets();
      presets[presetName] = getCurrentSettings();
      saveUserPresets(presets);
      updatePresetSelect();
      $('presetSelect').value = presetName;
      updatePresetMenuState();
      closePresetModal();
      showStatus(`プリセット「${presetName}」を上書き保存しました`, 'success');
    }
    
    function showNewPresetNameInput() {
      $('presetSaveOptions').style.display = 'none';
      $('newPresetNameInput').style.display = 'block';
      $('presetNameField').focus();
    }
    
    function cancelNewPresetName() {
      $('presetSaveOptions').style.display = 'flex';
      $('newPresetNameInput').style.display = 'none';
      $('presetNameField').value = '';
    }
    
    function performSaveAsNew() {
      const presets = loadUserPresets();
      const name = $('presetNameField').value.trim();
      
      if (!name) {
        alert('プリセット名を入力してください。');
        return;
      }
      
      if (name === '_default') {
        alert('「_default」という名前はプリセット名として使用できません。');
        return; // 処理を中断
      }

      if (name.length > 20) {
        alert('プリセット名は20文字以内にしてください。');
        return;
      }
      
      if (presets[name]) {
        if (!confirm(`プリセット「${name}」は既に存在します。上書きしますか？`)) {
          return;
        }
      } else if (Object.keys(presets).length >= 10) {
        alert('プリセットは最大10個まで保存できます。');
        return;
      }
      
      presets[name] = getCurrentSettings();
      saveUserPresets(presets);
      updatePresetSelect();
      $('presetSelect').value = name;
      updatePresetMenuState();
      closePresetModal();
      showStatus(`プリセット「${name}」を保存しました`, 'success');
    }
    
    function closePresetModal() {
      $('presetSaveModal').style.display = 'none';
    }

    function loadSelectedPreset() {
      const presets = loadUserPresets();
      const presetName = $('presetSelect').value;

      if (presetName === '_default') {
        // applyAllSettings(null) の代わりに loadSettingsToForm() を呼ぶ
        loadSettingsToForm();
        showStatus(`デフォルト設定を適用しました`, 'success');
        return; // デフォルトを適用して処理を終了
      }

      // 共有プリセット（管理者が全ユーザーへ共有したもの）
      if (presetName.indexOf(SHARED_PRESET_PREFIX) === 0) {
        const sharedName = presetName.slice(SHARED_PRESET_PREFIX.length);
        const shared = __sharedPresets && __sharedPresets[sharedName];
        if (shared && shared.settings) {
          applyAllSettings(shared.settings);
          showStatus(`共有プリセット「${sharedName}」を適用しました`, 'success');
        }
        return;
      }

      if (presets[presetName]) {
        applyAllSettings(presets[presetName]);
        showStatus(`プリセット「${presetName}」を適用しました`, 'success');
      }
    }

    function deleteSelectedPreset() {
      const select = $('presetSelect');
      const presetName = select.value;
      
      // [変更] プリセット名が空、またはデフォルト設定の場合は何もしない
      if (!presetName || presetName === '_default') {
        return;
      }

      // [変更] 削除ロジックをコールバック関数として定義
      const deleteLogic = () => {
        const presets = loadUserPresets();
        delete presets[presetName];
        saveUserPresets(presets);
        updatePresetSelect(); // ドロップダウンを更新
        showStatus(`プリセット「${presetName}」を削除しました`, 'success');
      };
      
      // [変更] 既存の項目削除モーダルを呼び出す
      // (注: モーダルのテキストは「この項目を...」ですが、動作は同じです)
      showDeleteItemConfirmModal(deleteLogic);
    }
    


    

    // ▼▼▼ [修正版] SVGビジュアルエディタ (リサイズハンドル + テキスト削除バグ修正版) ▼▼▼
const SvgVisualEditor = (function() {
  let currentSvgElement = null;
  let onSaveCallback = null;

  // 状態管理
  let selectedElements = [];
  let clipboard = [];
  let movingElements = [];
  let undoStack = [];
  let redoStack = [];

  // ドラッグ・操作用
  let isDragging = false;
  let isResizing = false; // ★リサイズ中フラグ
  let activeResizeHandle = null; // ★操作中のハンドル名 (nw, se, etc)
  let isBoxSelecting = false;
  let dragStartClientPos = { x: 0, y: 0 };
  let selectionBoxStart = { x: 0, y: 0 };

  // 移動・リサイズ前の初期状態キャッシュ
  let initialTransforms = new Map();
  let initialBBoxes = new Map(); // ★リサイズ計算用BBoxキャッシュ

  // UI要素
  let modal, canvas, controls, noSelection;
  let undoBtn, redoBtn;
  let selectionRectElement;
  let resizeHandlesGroup; // ★リサイズハンドル用グループ
  let inputs = {};
  let btnFront, btnForward, btnBackward, btnBack;

  // キャンバス設定用
  let inputCanvasW, inputCanvasH;
  let inputVbx, inputVby, inputVbw, inputVbh;

  function init() {
    modal = document.getElementById('svgEditorModal');
    canvas = document.getElementById('svgEditorCanvas');
    controls = document.getElementById('svgEditorControls');
    noSelection = document.getElementById('svgEditorNoSelection');
    undoBtn = document.getElementById('svgUndoBtn');
    redoBtn = document.getElementById('svgRedoBtn');

    btnFront = document.getElementById('svgBringFrontBtn');
    btnForward = document.getElementById('svgBringForwardBtn');
    btnBackward = document.getElementById('svgSendBackwardBtn');
    btnBack = document.getElementById('svgSendBackBtn');

    inputCanvasW = document.getElementById('svgCanvasWidth');
    inputCanvasH = document.getElementById('svgCanvasHeight');
    inputVbx = document.getElementById('svgViewBoxX');
    inputVby = document.getElementById('svgViewBoxY');
    inputVbw = document.getElementById('svgViewBoxW');
    inputVbh = document.getElementById('svgViewBoxH');

    inputs = {
      tagName: document.getElementById('svgEditTagName'),
      text: document.getElementById('svgEditText'),
      fillColor: document.getElementById('svgEditFillColor'),
      fillText: document.getElementById('svgEditFillText'),
      strokeColor: document.getElementById('svgEditStrokeColor'),
      strokeText: document.getElementById('svgEditStrokeText'),
      strokeWidth: document.getElementById('svgEditStrokeWidth'),
      scale: document.getElementById('svgEditScale'),
      scaleRange: document.getElementById('svgEditScaleRange'),
      opacity: document.getElementById('svgEditOpacity'),
      opacityRange: document.getElementById('svgEditOpacityRange'),
      fillOpacity: document.getElementById('svgEditFillOpacity'),
      fillOpacityRange: document.getElementById('svgEditFillOpacityRange'),
      strokeOpacity: document.getElementById('svgEditStrokeOpacity'),
      strokeOpacityRange: document.getElementById('svgEditStrokeOpacityRange')
    };

    if (undoBtn) undoBtn.addEventListener('click', undo);
    if (redoBtn) redoBtn.addEventListener('click', redo);
    document.getElementById('svgEditorSaveBtn').addEventListener('click', saveAndClose);
    document.getElementById('svgEditorCancelBtn').addEventListener('click', close);

    if (btnFront) btnFront.addEventListener('click', () => reorderSelection('front'));
    if (btnForward) btnForward.addEventListener('click', () => reorderSelection('forward'));
    if (btnBackward) btnBackward.addEventListener('click', () => reorderSelection('backward'));
    if (btnBack) btnBack.addEventListener('click', () => reorderSelection('back'));

    if (inputCanvasW) inputCanvasW.addEventListener('input', updateCanvasSize);
    if (inputCanvasH) inputCanvasH.addEventListener('input', updateCanvasSize);
    [inputVbx, inputVby, inputVbw, inputVbh].forEach(input => {
      if (input) input.addEventListener('input', updateCanvasViewBox);
    });

    const bindInput = (el, propName, linkedEl = null) => {
      if (!el) return;
      el.addEventListener('input', (e) => {
        if (linkedEl) linkedEl.value = e.target.value;
        applyPropertyChange(propName, e.target.value);
      });
    };

    bindInput(inputs.text, 'text');
    bindInput(inputs.fillColor, 'fill', inputs.fillText);
    bindInput(inputs.fillText, 'fill', inputs.fillColor);
    bindInput(inputs.strokeColor, 'stroke', inputs.strokeText);
    bindInput(inputs.strokeText, 'stroke', inputs.strokeColor);
    bindInput(inputs.strokeWidth, 'stroke-width');
    bindInput(inputs.opacity, 'opacity', inputs.opacityRange);
    bindInput(inputs.opacityRange, 'opacity', inputs.opacity);
    bindInput(inputs.fillOpacity, 'fill-opacity', inputs.fillOpacityRange);
    bindInput(inputs.fillOpacityRange, 'fill-opacity', inputs.fillOpacity);
    bindInput(inputs.strokeOpacity, 'stroke-opacity', inputs.strokeOpacityRange);
    bindInput(inputs.strokeOpacityRange, 'stroke-opacity', inputs.strokeOpacity);

    // スケール(均等)
    if (inputs.scale) {
      inputs.scale.addEventListener('input', (e) => {
        if (inputs.scaleRange) inputs.scaleRange.value = e.target.value;
        applyTransformChange();
      });
    }
    if (inputs.scaleRange) {
      inputs.scaleRange.addEventListener('input', (e) => {
        if (inputs.scale) inputs.scale.value = e.target.value;
        applyTransformChange();
      });
    }

    document.addEventListener('keydown', handleKeyDown);
  }

  function open(svgString, callback) {
    onSaveCallback = callback;
    canvas.innerHTML = svgString;

    currentSvgElement = canvas.querySelector('svg');
    if (!currentSvgElement) {
      alert('有効なSVGコードではありません。');
      return;
    }

    loadCanvasSettings();

    // 選択枠用
    selectionRectElement = document.createElementNS("http://www.w3.org/2000/svg", "rect");
    selectionRectElement.id = "selectionRect";
    selectionRectElement.style.fill = "rgba(0, 120, 255, 0.1)";
    selectionRectElement.style.stroke = "rgba(0, 120, 255, 0.5)";
    selectionRectElement.style.display = "none";
    currentSvgElement.appendChild(selectionRectElement);

    // ★リサイズハンドル用グループ作成
    createResizeHandles();

    // ▼▼▼ [修正] イベントリスナーを SVG ではなく canvas (親コンテナ) に設定 ▼▼▼
    canvas.addEventListener('mousedown', handleMouseDown);
    // ▲▲▲ [修正] ここまで ▲▲▲
    
    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('mouseup', handleMouseUp);

    undoStack = [];
    redoStack = [];
    selectedElements = [];
    updateUiState();

    modal.style.display = 'flex';
  }

  // ★ 8方向のハンドルを作成する関数
  function createResizeHandles() {
    resizeHandlesGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
    resizeHandlesGroup.id = "resizeHandles";
    resizeHandlesGroup.style.display = "none";

    const handles = [
      { id: 'nw', cursor: 'nw-resize' }, { id: 'n', cursor: 'n-resize' }, { id: 'ne', cursor: 'ne-resize' },
      { id: 'w', cursor: 'w-resize' },                                    { id: 'e', cursor: 'e-resize' },
      { id: 'sw', cursor: 'sw-resize' }, { id: 's', cursor: 's-resize' }, { id: 'se', cursor: 'se-resize' }
    ];

    handles.forEach(h => {
      const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
      rect.setAttribute('width', '8');
      rect.setAttribute('height', '8');
      rect.setAttribute('fill', '#fff');
      rect.setAttribute('stroke', '#4285F4');
      rect.setAttribute('stroke-width', '1');
      rect.style.cursor = h.cursor;
      rect.dataset.handle = h.id; // 識別用データ属性
      resizeHandlesGroup.appendChild(rect);
    });

    // 選択枠線（バウンディングボックス表示用）
    const outline = document.createElementNS("http://www.w3.org/2000/svg", "rect");
    outline.id = "resizeOutline";
    outline.setAttribute('fill', 'none');
    outline.setAttribute('stroke', '#4285F4');
    outline.setAttribute('stroke-width', '1');
    outline.setAttribute('stroke-dasharray', '4');
    outline.style.pointerEvents = 'none';
    resizeHandlesGroup.insertBefore(outline, resizeHandlesGroup.firstChild);

    currentSvgElement.appendChild(resizeHandlesGroup);
  }

  // ★ [追加] 全選択を実行する関数
  function selectAll() {
    if (!currentSvgElement) return;

    // 1. 現在の選択をクリア（ハイライト解除）
    selectedElements.forEach(el => el.classList.remove('svg-editor-selected'));
    selectedElements = [];

    // 2. 編集対象となる全要素を取得 (UIパーツや定義タグを除く)
    const allElements = currentSvgElement.querySelectorAll('*:not(defs):not(style):not(filter):not(#selectionRect):not(#resizeHandles):not(#resizeHandles *)');

    // 3. 有効な図形タグのみを選択リストに追加
    allElements.forEach(el => {
      if (['path', 'line', 'polyline', 'rect', 'circle', 'ellipse', 'text', 'g', 'polygon', 'image'].includes(el.tagName)) {
        selectedElements.push(el);
        el.classList.add('svg-editor-selected');
      }
    });

    // 4. UIとハンドルを更新（複数選択時は枠線のみ表示されます）
    updateUiState();
    updateResizeHandles();
  }

  // ★ [修正] 複数選択時にも枠線を表示するように変更
  function updateResizeHandles() {
    // 選択が0個なら非表示
    if (selectedElements.length === 0) {
      resizeHandlesGroup.style.display = "none";
      return;
    }

    // 1. 選択された全要素を含む「最小の矩形（バウンディングボックス）」を計算
    let minX = Infinity, minY = Infinity;
    let maxX = -Infinity, maxY = -Infinity;

    selectedElements.forEach(el => {
      const t = parseTransform(el);
      let bbox;
      try { bbox = el.getBBox(); } catch(e) { return; }

      // 各要素の「変形後の座標」を計算
      const elX = t.x + (bbox.x * t.scaleX);
      const elY = t.y + (bbox.y * t.scaleY);
      const elW = bbox.width * t.scaleX;
      const elH = bbox.height * t.scaleY;

      // 全体の最大・最小を更新
      if (elX < minX) minX = elX;
      if (elY < minY) minY = elY;
      if (elX + elW > maxX) maxX = elX + elW;
      if (elY + elH > maxY) maxY = elY + elH;
    });

    if (minX === Infinity) return; // 計算失敗時

    resizeHandlesGroup.style.display = "block";

    const width = maxX - minX;
    const height = maxY - minY;
    const midX = minX + width / 2;
    const midY = minY + height / 2;

    // 2. 青い点線枠（Outline）を更新
    const outline = resizeHandlesGroup.querySelector('#resizeOutline');
    outline.setAttribute('x', minX);
    outline.setAttribute('y', minY);
    outline.setAttribute('width', width);
    outline.setAttribute('height', height);

    // 3. リサイズハンドル（四角い点）の位置更新と表示制御
    const handles = Array.from(resizeHandlesGroup.querySelectorAll('rect:not(#resizeOutline)'));
    
    // ★ 複数選択時は、誤動作防止のためリサイズハンドル（四角）を非表示にする
    if (selectedElements.length > 1) {
        handles.forEach(h => h.style.display = 'none');
        return; 
    }

    // 単一選択時はハンドルを表示して配置
    handles.forEach(rect => {
      rect.style.display = 'block'; // 表示
      const id = rect.dataset.handle;
      const coords = {
        nw: { x: minX, y: minY }, n:  { x: midX, y: minY }, ne: { x: maxX, y: minY },
        w:  { x: minX, y: midY },                           e:  { x: maxX, y: midY },
        sw: { x: minX, y: maxY }, s:  { x: midX, y: maxY }, se: { x: maxX, y: maxY }
      };
      
      if (coords[id]) {
        rect.setAttribute('x', coords[id].x - 4);
        rect.setAttribute('y', coords[id].y - 4);
      }
    });
  }

  function loadCanvasSettings() {
    if (!currentSvgElement) return;
    let w = currentSvgElement.getAttribute('width');
    let h = currentSvgElement.getAttribute('height');
    let vb = currentSvgElement.getAttribute('viewBox');
    let vbX = 0, vbY = 0, vbW = 800, vbH = 600;
    if (vb) {
      const parts = vb.split(/\s+|,/).filter(Boolean).map(parseFloat);
      if (parts.length === 4) [vbX, vbY, vbW, vbH] = parts;
    } else {
      if (w) vbW = parseFloat(w);
      if (h) vbH = parseFloat(h);
      vb = `${vbX} ${vbY} ${vbW} ${vbH}`;
      currentSvgElement.setAttribute('viewBox', vb);
    }
    if (!w) currentSvgElement.setAttribute('width', vbW);
    if (!h) currentSvgElement.setAttribute('height', vbH);

    if (inputCanvasW) inputCanvasW.value = parseFloat(w || vbW);
    if (inputCanvasH) inputCanvasH.value = parseFloat(h || vbH);
    if (inputVbx) inputVbx.value = vbX;
    if (inputVby) inputVby.value = vbY;
    if (inputVbw) inputVbw.value = vbW;
    if (inputVbh) inputVbh.value = vbH;
  }

  function updateCanvasSize() {
    if (!currentSvgElement) return;
    currentSvgElement.setAttribute('width', inputCanvasW.value);
    currentSvgElement.setAttribute('height', inputCanvasH.value);
  }

  function updateCanvasViewBox() {
    if (!currentSvgElement) return;
    const x = inputVbx.value || 0;
    const y = inputVby.value || 0;
    const w = inputVbw.value || 800;
    const h = inputVbh.value || 600;
    currentSvgElement.setAttribute('viewBox', `${x} ${y} ${w} ${h}`);
  }

  function saveState() {
    undoStack.push(currentSvgElement.outerHTML);
    if (undoStack.length > 20) undoStack.shift();
    redoStack = [];
    updateUiState();
  }

  function undo() {
    if (undoStack.length === 0) return;
    redoStack.push(currentSvgElement.outerHTML);
    restoreState(undoStack.pop());
  }

  function redo() {
    if (redoStack.length === 0) return;
    undoStack.push(currentSvgElement.outerHTML);
    restoreState(redoStack.pop());
  }

  function restoreState(htmlState) {
    canvas.innerHTML = htmlState;
    currentSvgElement = canvas.querySelector('svg');
    
    // ▼▼▼ [修正] canvasへのリスナーは維持されるため、ここでの addEventListener は削除 ▼▼▼
    // currentSvgElement.addEventListener('mousedown', handleMouseDown); 
    // ▲▲▲ [修正] 削除済み ▲▲▲
    
    // 復元後に参照を再取得
    selectionRectElement = document.getElementById('selectionRect');
    resizeHandlesGroup = document.getElementById('resizeHandles');
    
    // ない場合は再作成
    if (!resizeHandlesGroup) createResizeHandles();
    if (!selectionRectElement) {
       selectionRectElement = document.createElementNS("http://www.w3.org/2000/svg", "rect");
       selectionRectElement.id = "selectionRect";
       currentSvgElement.appendChild(selectionRectElement);
    }

    loadCanvasSettings();
    clearSelection();
    updateUiState();
  }

  function getSvgPoint(evt) {
    const point = currentSvgElement.createSVGPoint();
    point.x = evt.clientX;
    point.y = evt.clientY;
    return point.matrixTransform(currentSvgElement.getScreenCTM().inverse());
  }

  function getLocalPoint(clientX, clientY, element) {
    const point = currentSvgElement.createSVGPoint();
    point.x = clientX;
    point.y = clientY;
    // parentNodeが存在しない場合のエラー回避
    const parent = element.parentNode || currentSvgElement;
    return point.matrixTransform(parent.getScreenCTM().inverse());
  }

  // ★ [修正] 選択中の図形は「あいまい判定」の対象外にする
  function findNearbyElement(clientX, clientY) {
    const HIT_RADIUS = 5;
    // 選択枠やハンドルなどを除外して検索
    const allElements = currentSvgElement.querySelectorAll('*:not(defs):not(style):not(filter):not(#selectionRect):not(#resizeHandles):not(#resizeHandles *)');

    for (let i = allElements.length - 1; i >= 0; i--) {
      const el = allElements[i];
      if (!['path', 'line', 'polyline', 'rect', 'circle', 'ellipse', 'text', 'g', 'polygon', 'image'].includes(el.tagName)) continue;
      
      // ▼▼▼ [追加] 既に選択されている要素は、あいまい判定（矩形エリア判定）から除外する ▼▼▼
      if (selectedElements.includes(el)) continue;
      // ▲▲▲ [追加] ここまで ▲▲▲

      try {
        const rect = el.getBoundingClientRect();
        if (clientX >= rect.left - HIT_RADIUS && clientX <= rect.right + HIT_RADIUS &&
            clientY >= rect.top - HIT_RADIUS && clientY <= rect.bottom + HIT_RADIUS) {
          return el;
        }
      } catch (e) {}
    }
    return null;
  }

  function handleMouseDown(evt) {
    if (evt.button !== 0) return;

    const target = evt.target;

    // ▼▼▼ [修正] 親コンテナ(canvas)の背景クリック時の処理を追加 ▼▼▼
    // グレーの背景部分をクリックした場合
    if (target === canvas) {
        evt.preventDefault();
        clearSelection(); // 選択を解除
        return;
    }
    // ▲▲▲ [修正] ここまで ▲▲▲

    if (!currentSvgElement) return; // 安全策

    // ★ ハンドルクリック判定
    if (target.parentNode === resizeHandlesGroup && target.dataset.handle) {
        evt.preventDefault();
        isResizing = true;
        activeResizeHandle = target.dataset.handle;
        saveState();
        
        // 初期状態を記録
        const el = selectedElements[0];
        initialTransforms.set(el, parseTransform(el));
        try { initialBBoxes.set(el, el.getBBox()); } catch(e){}
        dragStartClientPos = { x: evt.clientX, y: evt.clientY };
        return;
    }

    let isBg = target === currentSvgElement;

    // if (isBg || target.id === 'selectionRect') {
    //   const nearbyEl = findNearbyElement(evt.clientX, evt.clientY);
    //   if (nearbyEl) {
    //     target = nearbyEl;
    //     isBg = false;
    //   }
    // }

    const isElement = !isBg && target !== selectionRectElement && target.parentNode !== resizeHandlesGroup;
    const point = getSvgPoint(evt);
    const isMultiSelect = evt.shiftKey || evt.ctrlKey || evt.metaKey;

    if (isElement) {
      if (isMultiSelect) {
        toggleSelection(target);
        if (selectedElements.includes(target)) setupDrag(evt);
      } else {
        if (!selectedElements.includes(target)) selectSingle(target);
        setupDrag(evt);
      }
    } else {
      evt.preventDefault();
      if (!isMultiSelect) clearSelection();
      isBoxSelecting = true;
      selectionBoxStart = point;
      updateSelectionBox(point);
      selectionRectElement.style.display = 'block';
    }
  }

  function setupDrag(evt) {
    saveState();
    isDragging = true;
    dragStartClientPos = { x: evt.clientX, y: evt.clientY };
    initialTransforms.clear();
    
    // 選択された要素の親が選択に含まれていないものだけを移動対象にする
    movingElements = selectedElements.filter(el => {
        let parent = el.parentNode;
        while(parent && parent !== currentSvgElement) {
            if(selectedElements.includes(parent)) return false;
            parent = parent.parentNode;
        }
        return true;
    });

    movingElements.forEach(el => {
      initialTransforms.set(el, parseTransform(el));
    });
  }

  function handleMouseMove(evt) {
    if (!currentSvgElement) return;
    
    // ★ リサイズ処理 (アスペクト比維持 + 位置補正)
    if (isResizing && selectedElements.length === 1) {
        const el = selectedElements[0];
        const initT = initialTransforms.get(el);
        const bbox = initialBBoxes.get(el);
        
        if (initT && bbox) {
            // 1. マウス移動量 (dx, dy) を計算
            const startLocal = getLocalPoint(dragStartClientPos.x, dragStartClientPos.y, el);
            const currentLocal = getLocalPoint(evt.clientX, evt.clientY, el);
            const dx = currentLocal.x - startLocal.x;
            const dy = currentLocal.y - startLocal.y;

            // 2. 元のサイズとアスペクト比
            const oldW = bbox.width * initT.scaleX;
            const oldH = bbox.height * initT.scaleY;
            const aspectRatio = oldW / oldH;

            // 3. ハンドル操作に応じた暫定的な新しいサイズを計算
            let newW = oldW;
            let newH = oldH;

            if (activeResizeHandle.includes('e')) newW += dx;
            if (activeResizeHandle.includes('w')) newW -= dx;
            if (activeResizeHandle.includes('s')) newH += dy;
            if (activeResizeHandle.includes('n')) newH -= dy;

            // 最小サイズの制限
            if (newW < 1) newW = 1;
            if (newH < 1) newH = 1;

            // 4. ★四隅のハンドルの場合、アスペクト比を強制適用★
            if (['nw', 'ne', 'sw', 'se'].includes(activeResizeHandle)) {
                // 変化率が大きい方に合わせる（または単純に幅基準で高さを決める）
                // ここでは「幅の変化」を基準に高さを調整する方式を採用
                if (Math.abs(newW - oldW) > Math.abs((newH - oldH) * aspectRatio)) {
                    newH = newW / aspectRatio;
                } else {
                    newW = newH * aspectRatio;
                }
            }

            // 5. 新しいスケール率を計算
            const newScaleX = newW / bbox.width;
            const newScaleY = newH / bbox.height;

            // 6. ★アンカーポイント（固定点）基準で新しい座標(newX, newY)を逆算★
            // SVGのscaleは原点基準なので、見た目の位置を保つためにtranslate(x, y)を補正する
            
            let newX = initT.x;
            let newY = initT.y;

            // X軸の補正
            if (activeResizeHandle.includes('w')) {
                // 左側ハンドル操作時：右端(Right)を固定
                // Fixed Right = initT.x + (bbox.x + bbox.width) * initT.scaleX
                // New X = Fixed Right - (bbox.x + bbox.width) * newScaleX
                const fixedRight = initT.x + (bbox.x + bbox.width) * initT.scaleX;
                newX = fixedRight - (bbox.x + bbox.width) * newScaleX;
            } else {
                // 右側ハンドル or 上下のみ操作時：左端(Left)を固定（原点ズレ補正含む）
                // Fixed Left = initT.x + bbox.x * initT.scaleX
                // New X = Fixed Left - bbox.x * newScaleX
                const fixedLeft = initT.x + bbox.x * initT.scaleX;
                newX = fixedLeft - bbox.x * newScaleX;
            }

            // Y軸の補正
            if (activeResizeHandle.includes('n')) {
                // 上側ハンドル操作時：下端(Bottom)を固定
                // Fixed Bottom = initT.y + (bbox.y + bbox.height) * initT.scaleY
                // New Y = Fixed Bottom - (bbox.y + bbox.height) * newScaleY
                const fixedBottom = initT.y + (bbox.y + bbox.height) * initT.scaleY;
                newY = fixedBottom - (bbox.y + bbox.height) * newScaleY;
            } else {
                // 下側ハンドル or 左右のみ操作時：上端(Top)を固定（原点ズレ補正含む）
                // Fixed Top = initT.y + bbox.y * initT.scaleY
                // New Y = Fixed Top - bbox.y * newScaleY
                const fixedTop = initT.y + bbox.y * initT.scaleY;
                newY = fixedTop - bbox.y * newScaleY;
            }

            setTransform(el, newX, newY, newScaleX, newScaleY, initT.otherTransforms);
            updateResizeHandles(); 
        }
        return;
    }

    // --- 以下、通常の移動(Drag)や範囲選択の処理 (変更なし) ---
    const point = getSvgPoint(evt);
    if (isDragging) {
      movingElements.forEach(el => {
        const initT = initialTransforms.get(el);
        if (initT) {
          const startLocal = getLocalPoint(dragStartClientPos.x, dragStartClientPos.y, el);
          const currentLocal = getLocalPoint(evt.clientX, evt.clientY, el);
          
          const dx = currentLocal.x - startLocal.x;
          const dy = currentLocal.y - startLocal.y;

          setTransform(el, initT.x + dx, initT.y + dy, initT.scaleX, initT.scaleY, initT.otherTransforms);
        }
      });
      updateResizeHandles(); 
    } else if (isBoxSelecting) {
      updateSelectionBox(point);
    }
  }





  function handleMouseUp(evt) {
    if (isDragging) {
      isDragging = false;
      initialTransforms.clear();
      movingElements = [];
    } else if (isResizing) {
      isResizing = false;
      activeResizeHandle = null;
    } else if (isBoxSelecting) {
      isBoxSelecting = false;
      selectElementsInBox();
      selectionRectElement.style.display = 'none';
    }
  }

  function selectElementsInBox() {
    const boxRect = selectionRectElement.getBoundingClientRect();
    const allElements = currentSvgElement.querySelectorAll('*:not(defs):not(style):not(filter):not(#selectionRect):not(#resizeHandles):not(#resizeHandles *)');
    allElements.forEach(el => {
      if (!['g', 'text', 'path', 'rect', 'circle', 'ellipse', 'image', 'polygon', 'polyline'].includes(el.tagName)) return;
      try {
        const elRect = el.getBoundingClientRect();
        const isInside = (
          elRect.left >= boxRect.left &&
          elRect.right <= boxRect.right &&
          elRect.top >= boxRect.top &&
          elRect.bottom <= boxRect.bottom
        );
        if (isInside) {
          addToSelection(el);
        }
      } catch (e) {}
    });
  }

  function updateSelectionBox(currentPoint) {
    const x = Math.min(selectionBoxStart.x, currentPoint.x);
    const y = Math.min(selectionBoxStart.y, currentPoint.y);
    const width = Math.abs(currentPoint.x - selectionBoxStart.x);
    const height = Math.abs(currentPoint.y - selectionBoxStart.y);
    selectionRectElement.setAttribute('x', x);
    selectionRectElement.setAttribute('y', y);
    selectionRectElement.setAttribute('width', width);
    selectionRectElement.setAttribute('height', height);
  }

  // ★ [追加] コピー
  function copySelection() {
    if (selectedElements.length === 0) return;
    // 選択された要素を複製（ディープコピー）してクリップボードに保存
    clipboard = selectedElements.map(el => el.cloneNode(true));
  }

  // ★ [追加] 切り取り
  function cutSelection() {
    if (selectedElements.length === 0) return;
    saveState(); // 履歴保存
    copySelection(); // まずコピー
    deleteSelection(); // その後削除
  }

  // ★ [追加] 貼り付け
  function pasteClipboard() {
    if (clipboard.length === 0 || !currentSvgElement) return;
    saveState(); // 履歴保存
    
    clearSelection(); // 現在の選択を解除

    // クリップボードの内容をさらに複製して貼り付け（連続ペースト対応）
    const newElements = clipboard.map(el => el.cloneNode(true));

    newElements.forEach(el => {
      // IDが重複しないように削除（またはユニークIDを生成してもよい）
      el.removeAttribute('id');

      // 重なって見えなくならないよう、少し右下にずらす (+10px)
      const t = parseTransform(el);
      setTransform(el, t.x + 10, t.y + 10, t.scaleX, t.scaleY, t.otherTransforms);

      // ハンドル等の手前に挿入されないよう配慮しつつ追加
      // (単純に appendChild するとハンドルの上に乗る可能性があるが、
      //  reorderSelection や updateUiState で整合性は取れるため appendChild でOK)
      
      // ハンドルや選択枠がある場合は、その手前に挿入
      if (resizeHandlesGroup && resizeHandlesGroup.parentNode === currentSvgElement) {
          currentSvgElement.insertBefore(el, resizeHandlesGroup);
      } else {
          currentSvgElement.appendChild(el);
      }

      // 貼り付けた要素を選択状態にする
      addToSelection(el);
    });
    
    // クリップボードの中身を、今貼り付けたものに更新（次回ペーストでさらにずらすため）
    // これをしないと、連続ペーストしたときに全て同じ位置に重なってしまう
    clipboard = newElements.map(el => el.cloneNode(true));
  }

  function handleKeyDown(e) {
    if (modal.style.display === 'none') return;

    // ▼▼▼ 変数定義 ▼▼▼
    const key = e.key.toLowerCase();
    const isCtrl = e.ctrlKey || e.metaKey;

    // ★ テキスト入力中はショートカットキーを無効化
    const target = e.target;
    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
      return;
    }

    // ▼▼▼ 全選択 (Ctrl+A) ▼▼▼
    if (isCtrl && key === 'a') {
      e.preventDefault();
      selectAll();
      return;
    }

    // ▼▼▼ コピー (Ctrl+C) ▼▼▼
    if (isCtrl && key === 'c') {
      e.preventDefault();
      copySelection();
      return;
    }

    // ▼▼▼ 切り取り (Ctrl+X) ▼▼▼
    if (isCtrl && key === 'x') {
      e.preventDefault();
      cutSelection();
      return;
    }

    // ▼▼▼ 貼り付け (Ctrl+V) ▼▼▼
    if (isCtrl && key === 'v') {
      e.preventDefault();
      pasteClipboard();
      return;
    }

    // ▼▼▼ 元に戻す (Ctrl+Z) ▼▼▼
    if (isCtrl && key === 'z') {
      e.preventDefault();
      undo(); 
      return;
    }

    // ▼▼▼ やり直す (Ctrl+Y) ▼▼▼
    if (isCtrl && key === 'y') {
      e.preventDefault();
      redo(); 
      return;
    }

    // ▼▼▼ 削除 (Delete / Backspace) ▼▼▼
    if (['Delete', 'Backspace'].includes(e.key)) {
      e.preventDefault();
      deleteSelection();
      return;
    }
    
    // ▼▼▼ 矢印キーでの移動 ▼▼▼
    if (selectedElements.length > 0 && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
      e.preventDefault();
      const step = e.shiftKey ? 10 : 1;
      let dx = 0, dy = 0;
      if (e.key === 'ArrowUp') dy = -step;
      if (e.key === 'ArrowDown') dy = step;
      if (e.key === 'ArrowLeft') dx = -step;
      if (e.key === 'ArrowRight') dx = step;
      
      saveState();
      
      // 親要素も選択されている場合は、子要素を二重に移動させないためのフィルタリング
      const targets = selectedElements.filter(el => {
          let parent = el.parentNode;
          while(parent && parent !== currentSvgElement) {
              if(selectedElements.includes(parent)) return false;
              parent = parent.parentNode;
          }
          return true;
      });

      targets.forEach(el => {
        const t = parseTransform(el);
        setTransform(el, t.x + dx, t.y + dy, t.scaleX, t.scaleY, t.otherTransforms);
      });
      updateResizeHandles();
    }
  }




  function deleteSelection() {
    if (selectedElements.length === 0) return;
    saveState();
    selectedElements.forEach(el => {
      if (el.parentNode) el.parentNode.removeChild(el);
    });
    clearSelection();
  }

  function selectSingle(el) { clearSelection(); addToSelection(el); }

  function toggleSelection(el) {
    if (selectedElements.includes(el)) removeFromSelection(el);
    else addToSelection(el);
  }

  function addToSelection(el) {
    if (!selectedElements.includes(el)) {
      selectedElements.push(el);
      el.classList.add('svg-editor-selected');

      // ▼▼▼ [追加] 線の太さを維持する属性を付与 ▼▼▼
      // これにより、拡大縮小しても線の太さが変わらなくなります
      el.setAttribute('vector-effect', 'non-scaling-stroke');
      // ▲▲▲ [追加] ここまで ▲▲▲
    }
    updateUiState();
    updateResizeHandles();
  }

  function removeFromSelection(el) {
    const idx = selectedElements.indexOf(el);
    if (idx > -1) {
      selectedElements.splice(idx, 1);
      el.classList.remove('svg-editor-selected');
    }
    updateUiState();
    updateResizeHandles();
  }

  function clearSelection() {
    selectedElements.forEach(el => el.classList.remove('svg-editor-selected'));
    selectedElements = [];
    updateUiState();
    if (resizeHandlesGroup) resizeHandlesGroup.style.display = "none";
  }

  // ★ [修正] 縦横個別のスケールに対応
  function parseTransform(el) {
    const transform = el.getAttribute('transform') || '';
    let x = 0, y = 0, scaleX = 1, scaleY = 1;

    const translateMatch = transform.match(/translate\(([^,]+)(?:[,\s]+([^)]+))?\)/);
    if (translateMatch) {
      x = parseFloat(translateMatch[1]) || 0;
      y = parseFloat(translateMatch[2]) || 0;
    }
    
    const scaleMatch = transform.match(/scale\(([^)]+)\)/);
    if (scaleMatch) {
      // scale(1.5) または scale(1.5, 0.5) に対応
      const parts = scaleMatch[1].split(/[,\s]+/).map(parseFloat);
      scaleX = parts[0] || 1;
      scaleY = (parts[1] !== undefined) ? parts[1] : scaleX;
    }

    let otherTransforms = transform
        .replace(/translate\([^)]+\)/, '')
        .replace(/scale\([^)]+\)/, '')
        .trim();
    return { x, y, scaleX, scaleY, otherTransforms };
  }

  // ★ [修正] 縦横個別のスケールを設定
  function setTransform(el, x, y, scaleX, scaleY, otherTransforms = '') {
    const newTransform = `translate(${x}, ${y}) scale(${scaleX}, ${scaleY}) ${otherTransforms}`;
    el.setAttribute('transform', newTransform.trim());
  }

  function applyPropertyChange(propName, value) {
    if (selectedElements.length === 0) return;
    selectedElements.forEach(el => {
      if (propName === 'text') {
        if (['text', 'tspan'].includes(el.tagName.toLowerCase())) {
          el.textContent = value;
        }
      } else {
        el.setAttribute(propName, value);
        if (['fill', 'stroke', 'stroke-width', 'font-size', 'font-family', 'opacity', 'fill-opacity', 'stroke-opacity'].includes(propName)) {
          el.style.setProperty(propName, value, 'important');
        }
      }
    });
  }

  // ★ [修正] スライダー操作時に中心基準で拡大縮小する
  function applyTransformChange() {
    if (selectedElements.length === 0) return;
    const newScale = parseFloat(inputs.scale.value) || 1;

    selectedElements.forEach(el => {
      const t = parseTransform(el); // 現在の状態（変更前）を取得
      
      let bbox;
      try { 
        bbox = el.getBBox(); // 図形のバウンディングボックスを取得
      } catch (e) {
        // BBoxが取得できない場合は、補正なしでスケールのみ適用
        setTransform(el, t.x, t.y, newScale, newScale, t.otherTransforms);
        return;
      }

      // 1. 図形のローカル中心座標を計算
      const cx = bbox.x + (bbox.width / 2);
      const cy = bbox.y + (bbox.height / 2);

      // 2. 中心を維持するための新しい位置(translate)を逆算
      // 公式: 新しい位置 = 現在の位置 + (中心座標 * (現在のスケール - 新しいスケール))
      // これにより、スケール変化によって生じる「原点からの距離のズレ」を相殺します。
      const newX = t.x + cx * (t.scaleX - newScale);
      const newY = t.y + cy * (t.scaleY - newScale);

      // 3. 補正した位置と新しいスケールを適用
      setTransform(el, newX, newY, newScale, newScale, t.otherTransforms);
    });

    updateResizeHandles();
  }

  function reorderSelection(action) {
    if (selectedElements.length === 0) return;
    saveState();
    const elements = [...selectedElements];
    elements.forEach(el => {
      const p = el.parentNode;
      if (!p) return;
      
      // ★ハンドルの前面維持のため、挿入位置に注意
      const insertAnchor = (node) => {
          if (node === resizeHandlesGroup || node === selectionRectElement) return false;
          return true;
      };

      if (action === 'front') {
        // 通常コンテンツの最後へ（ハンドルの手前）
        if (resizeHandlesGroup && resizeHandlesGroup.parentNode === p) {
            p.insertBefore(el, resizeHandlesGroup);
        } else {
            p.appendChild(el);
        }
      } else if (action === 'back') {
        let firstContent = p.firstChild;
        while (firstContent && (['defs', 'style', 'metadata'].includes(firstContent.tagName))) {
          firstContent = firstContent.nextElementSibling;
        }
        p.insertBefore(el, firstContent);
      } else if (action === 'forward') {
        let next = el.nextElementSibling;
        while(next && (next === selectionRectElement || next === resizeHandlesGroup)) {
            next = next.nextElementSibling;
        }
        if (next) {
          p.insertBefore(el, next.nextElementSibling);
        }
      } else if (action === 'backward') {
        const prev = el.previousElementSibling;
        if (prev && !['defs', 'style', 'metadata'].includes(prev.tagName)) {
          p.insertBefore(el, prev);
        }
      }
    });
    
    // 選択枠とハンドルを最前面に維持
    if (resizeHandlesGroup) currentSvgElement.appendChild(resizeHandlesGroup);
    if (selectionRectElement) currentSvgElement.appendChild(selectionRectElement);
  }

  function ensureHex(color) {
    if (!color || color === 'none' || color === 'transparent') return '#000000';
    if (/^#[0-9A-F]{6}$/i.test(color)) return color;
    const rgb = color.match(/\d+/g);
    if (rgb && rgb.length >= 3) {
      const r = parseInt(rgb[0]);
      const g = parseInt(rgb[1]);
      const b = parseInt(rgb[2]);
      return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
    }
    return '#000000';
  }

  function updateUiState() {
    if (undoBtn) undoBtn.disabled = undoStack.length === 0;
    if (redoBtn) redoBtn.disabled = redoStack.length === 0;
    
    if (selectedElements.length === 0) {
      noSelection.style.display = 'block';
      controls.style.display = 'none';
    } else {
      noSelection.style.display = 'none';
      controls.style.display = 'block';
      const lastEl = selectedElements[selectedElements.length - 1];
      inputs.tagName.value = selectedElements.length > 1 ? `Multiple (${selectedElements.length})` : lastEl.tagName;
      inputs.text.value = lastEl.textContent ? lastEl.textContent.trim() : '';
      
      const style = getComputedStyle(lastEl);
      const fill = style.fill && style.fill !== 'none' ? style.fill : (lastEl.getAttribute('fill') || '#000000');
      const stroke = style.stroke && style.stroke !== 'none' ? style.stroke : (lastEl.getAttribute('stroke') || '#000000');
      const strokeW = style.strokeWidth || lastEl.getAttribute('stroke-width');

      updateColorInput(inputs.fillColor, inputs.fillText, fill);
      updateColorInput(inputs.strokeColor, inputs.strokeText, stroke);
      inputs.strokeWidth.value = parseFloat(strokeW) || 0;

      const op = style.opacity || lastEl.getAttribute('opacity') || 1;
      const fillOp = style.fillOpacity || lastEl.getAttribute('fill-opacity') || 1;
      const strokeOp = style.strokeOpacity || lastEl.getAttribute('stroke-opacity') || 1;

      updateRangeInput(inputs.opacity, inputs.opacityRange, op);
      updateRangeInput(inputs.fillOpacity, inputs.fillOpacityRange, fillOp);
      updateRangeInput(inputs.strokeOpacity, inputs.strokeOpacityRange, strokeOp);

      const t = parseTransform(lastEl);
      if (inputs.scale) inputs.scale.value = t.scaleX; // 便宜上Xを採用
      if (inputs.scaleRange) inputs.scaleRange.value = t.scaleX;
    }
  }

  function updateColorInput(colorInput, textInput, value) {
    if (!textInput) return;
    textInput.value = value;
    if (colorInput) {
      const hexValue = ensureHex(value);
      colorInput.value = hexValue;
    }
  }

  function updateRangeInput(numInput, rangeInput, value) {
    if (numInput) numInput.value = value;
    if (rangeInput) rangeInput.value = value;
  }

  function saveAndClose() {
    // 保存前にUI用要素を削除
    if (selectionRectElement && selectionRectElement.parentNode) {
      selectionRectElement.parentNode.removeChild(selectionRectElement);
      selectionRectElement = null;
    }
    if (resizeHandlesGroup && resizeHandlesGroup.parentNode) {
        resizeHandlesGroup.parentNode.removeChild(resizeHandlesGroup);
        resizeHandlesGroup = null;
    }
    
    if (currentSvgElement) {
        const selectedEls = currentSvgElement.querySelectorAll('.svg-editor-selected');
        selectedEls.forEach(el => el.classList.remove('svg-editor-selected'));
    }
    
    clearSelection();

    const newSvgString = currentSvgElement.outerHTML;
    if (onSaveCallback) {
      onSaveCallback(newSvgString);
    }

    close();
  }

  function close() {
    // ▼▼▼ [修正] canvas からリスナーを削除 ▼▼▼
    canvas.removeEventListener('mousedown', handleMouseDown);
    // ▲▲▲ [修正] ここまで ▲▲▲
    
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('mouseup', handleMouseUp);

    modal.style.display = 'none';
    canvas.innerHTML = '';
    currentSvgElement = null;
    selectedElements = [];

    if (typeof window.adjustResponsiveLayout === 'function') {
      window.adjustResponsiveLayout();
    }
  }

  document.addEventListener('DOMContentLoaded', init);

  return { open: open };
})();
// ▲▲▲ [修正完了] SvgVisualEditor ここまで ▲▲▲







    // ★★★ ここから新規追加 (SlideEditor モジュール) ★★★

// ★★★ [差し替え] SlideEditor モジュール (編集ボタン廃止・クリアボタン追加) ★★★
const SlideEditor = (function() {
    let slideData = []; // 現在のJSONデータ（オブジェクト配列）
    let currentIndex = 0; // 現在編集中のスライドインデックス
    let isEditorActive = false; // エディタUIがアクティブか
    let renderCache = {};

    // --- DOM要素 ---
    let editorContainer = null;
    // let toggleButton = null; // ◀ [削除] 編集ボタン
    let statusContainer = null;
    let mainTextarea = null;
    // let validationStatusTextEl = null; // ◀ [削除] ステータス表示
    let step1Card = null;
    let previewToggleButton = null; // ★ プレビュー表示/非表示ボタン

    /**
     * 1. 初期化 (toggleButton 関連を削除)
     */
    function init() {
        mainTextarea = $('slideDataInput');
        statusContainer = $('validationStatus'); 
        previewToggleButton = $('showLastSlideBtn'); 

        try {
            const h2 = Array.from(document.querySelectorAll('.card-header')).find(el => el.textContent.trim().startsWith('Geminiで'));
            if (h2) {
                step1Card = h2.closest('.card');
            }
        } catch(e) { console.warn('Could not find Step 1 card to hide.'); }

        // ▼▼▼ [削除] toggleButton の作成とイベントリスナー ▼▼▼
        // (toggleButton関連のコードをすべて削除)
        // ▲▲▲ [削除] ここまで ▲▲▲

        editorContainer = document.createElement('div');
        editorContainer.id = 'slideEditorUI';
        editorContainer.style.display = 'none';
        editorContainer.style.border = '1px solid var(--border-color)';
        editorContainer.style.borderRadius = 'var(--radius-md)';
        editorContainer.style.padding = '1.5rem';
        editorContainer.style.background = 'var(--bg-surface)';
        editorContainer.style.marginBottom = '1rem';
        
        // [変更] 挿入位置を buttonGroup の *後* に変更
        const buttonGroup = $('buttonGroup'); 
        if (buttonGroup && buttonGroup.parentNode) {
            // buttonGroup.nextSibling があればその前に挿入、なければ末尾に追加
            buttonGroup.parentNode.insertBefore(editorContainer, buttonGroup.nextSibling); 
        } else {
            mainTextarea.parentNode.insertBefore(editorContainer, mainTextarea.nextSibling);
        }

        // --- エディタコンテナのイベントリスナー (変更なし) ---
        editorContainer.addEventListener('click', function(event) {
            // ナビゲーションボタン
            const navBtn = event.target.closest('#editorPrevBtn, #editorNextBtn');
            if (navBtn && !navBtn.disabled) {
                event.preventDefault();
                if (navBtn.id === 'editorPrevBtn' && currentIndex > 0) {
                    currentIndex--;
                    renderEditorUI();
                } else if (navBtn.id === 'editorNextBtn' && currentIndex < slideData.length - 1) {
                    currentIndex++;
                    renderEditorUI();
                }

                updatePreviewButtonForEditor(true);

                return;
            }
            // Add/Deleteボタン
            const actionBtn = event.target.closest('button[data-action]');
            if (actionBtn) {
                event.preventDefault();
                const action = actionBtn.dataset.action;
                const pathString = actionBtn.dataset.path;
                try {
                    if (action === 'add') {
                        const path = pathString.replace('editor_field_', '').split('_');
                        const { parent, lastKey } = getObjectFromPath(path);
                        if (!Array.isArray(parent[lastKey])) throw new Error(`Target is not an array: ${pathString}`);
                        const array = parent[lastKey];
                        const newItem = getNewItemShape(lastKey, array);
                        array.push(newItem);
                        renderEditorUI();

                    } else if (action === 'delete') {
                
                // 1. 実行する削除ロジックを関数として定義
                const deleteLogic = () => {
                    const path = pathString.replace('editor_field_', '').split('_');
                    const index = parseInt(path.pop(), 10);
                    const { parent, lastKey } = getObjectFromPath(path); // (参照元は同じ)
                    if (!Array.isArray(parent[lastKey])) throw new Error(`Target is not an array: ${pathString}`);
                    parent[lastKey].splice(index, 1);
                    renderEditorUI(); // (参照元は同じ)
                };

                // 2. window.confirm の代わりに、新しいモーダルを呼び出す (修正)
                showDeleteItemConfirmModal(deleteLogic);
            
            }

                } catch (e) {
                    console.error("Failed to update array:", e);
                    showStatus('エラー: 項目の追加/削除に失敗しました', 'error');
                }
                return;
            }
        });
        editorContainer.addEventListener('input', function(event) {
            const field = event.target.closest('input, textarea, select');
            if (!field || !field.id) return;
            if (field.type === 'color' && field.id.endsWith('_picker')) {
                const textInput = $(field.id.replace('_picker', ''));
                if (textInput) {
                    textInput.value = field.value;
                    updateDataFromField(textInput);
                }
            } else if (field.type === 'text' && $(field.id + '_picker')) {
                const picker = $(field.id + '_picker');
                if (isColorString(field.value)) {
                    picker.value = field.value;
                    field.style.borderColor = 'var(--border-color)';
                } else {
                    field.style.borderColor = 'var(--danger-color)';
                }
                updateDataFromField(field);
            } else {
                updateDataFromField(field);
            }
            if (field.tagName === 'TEXTAREA' && field.nextElementSibling && field.nextElementSibling.classList.contains('svg-preview-container')) {
                // 入力されたSVGコードをプレビューエリアに反映
                field.nextElementSibling.innerHTML = field.value;
            }
        });
    }

    /**
     * 2. [削除] toggleEditorView 関数
     */
    // function toggleEditorView() { ... } // ◀ [削除]

    /**
     * 2. [移植] setEditorMode (モード制御のコア)
     */
    function setEditorMode(shouldActivate, options = {}) {
        const validationDiv = $('jsonValidation'); // [変更] 基準を validationDiv に
        const editorDiv = editorContainer;
        const buttonGroupDiv = $('buttonGroup');
        const textareaDiv = mainTextarea;

        if (!validationDiv || !editorDiv || !buttonGroupDiv || !textareaDiv) {
            console.error('SlideEditor: レイアウト変更に必要なDOM要素が見つかりません');
            return false;
        }

        const parent = validationDiv.parentNode; // [変更] 親を取得

        if (shouldActivate) {
            // 【編集モードへ】
            if (isEditorActive) return true; // 既にアクティブ
            try {
                const parsed = JSON.parse(textareaDiv.value);
                if (!Array.isArray(parsed) || parsed.length === 0) {
                    throw new Error('データが配列でないか、空です');
                }
                slideData = parsed;

                slideData.forEach(slide => {
                    // ★SVG/チャート生成バグ修正(2026-06-26): 生成でPNG化された画像を、退避済みの元データへ戻す。
                    //   info:'svg_code'/'chart' のオブジェクトのままだと createFormField が画像分解(Nano Banana)UIを出すため、
                    //   svg_code → 元SVG文字列(SVGビジュアルエディタ=系統2) / chart → 元チャートJSON文字列
                    //   (直後の startsWith('{') 正規化でチャートオブジェクト化＝データ編集フォーム)へ復元する。
                    if (slide.image && typeof slide.image === 'object' && (slide.image.info === 'svg_code' || slide.image.info === 'chart')) {
                        if (slide.image.info === 'svg_code') {
                            const svgSrc = [slide._originalImage, slide.image.nativeSvg]
                                .find(s => typeof s === 'string' && s.trim().toLowerCase().startsWith('<svg'));
                            if (svgSrc) slide.image = svgSrc.trim();
                        } else if (typeof slide._originalImage === 'string' && slide._originalImage.trim().startsWith('{')) {
                            slide.image = slide._originalImage.trim();
                        }
                    }
                    if (typeof slide.image === 'string' && slide.image.trim().startsWith('{')) {
                        try {
                            // 文字列をJSONとしてパースを試みる
                            const chartObj = JSON.parse(slide.image);
                            // chartTypeが含まれていればグラフとみなしてオブジェクトに置換
                            if (chartObj && chartObj.chartType) {
                                slide.image = chartObj;
                            }
                        } catch (e) {
                            // パース失敗時は文字列のままにする（何もしない）
                        }
                    }
                });

            } catch (e) {
                showJsonErrorModal('エラー: JSONデータが不正なため編集モードに移行できません\n' + e.message); // [変更] エラーモーダル呼び出し
                return false;
            }

            textareaDiv.style.display = 'none';
            if (step1Card) step1Card.style.display = 'none';
            
            // [変更] ボタンを validationDiv の *直後* に移動
            parent.insertBefore(buttonGroupDiv, validationDiv.nextSibling); 
            
            editorDiv.style.display = 'block';
            buttonGroupDiv.style.marginBottom = '1rem';
            buttonGroupDiv.style.marginTop = '0';
            currentIndex = 0;
            isEditorActive = true;
            renderEditorUI();
            updatePreviewButtonForEditor(true); // ★ プレビューボタン更新
            
            // [変更] ステータス表示(jsonValidation)は非表示のままにする
            validationDiv.style.display = 'none';

            return true;

        } else {
            // 【テキストモードへ】
            if (!isEditorActive) return true; // 既に非アクティブ
            
            try {
                textareaDiv.value = JSON.stringify(slideData, null, 2);
            } catch (e) {
                showJsonErrorModal('エラー: 編集内容をテキストに変換できませんでした\n' + e.message); // [変更] エラーモーダル呼び出し
            }

            textareaDiv.style.display = 'block';
            if (step1Card) step1Card.style.display = 'block';
            editorDiv.style.display = 'none';
            
            // [変更] ボタンを editorDiv の *直後* (つまりテキストエリアの下) に移動
            parent.insertBefore(buttonGroupDiv, editorDiv.nextSibling); 
            
            buttonGroupDiv.style.marginBottom = '';
            buttonGroupDiv.style.marginTop = '';
            isEditorActive = false;
            updatePreviewButtonForEditor(false); // ★ プレビューボタン更新

            if (!options.skipValidation) {
                // [変更] テキストモードに戻ったときは、ステータス表示はせず、
                // エラーがあればモーダルを出す (validateJSONが処理)
                suppressEditorAutoActivation = true; // 無限ループ防止
                try {
                    validateJSON(); 
                } finally {
                    suppressEditorAutoActivation = false;
                }
            }
            
            // [変更] ステータス表示(jsonValidation)を非表示に戻す
            validationDiv.style.display = 'none';

            textareaDiv.focus();
            return true;
        }
    }

    /**
     * [移植] enterEditorMode (自動実行用)
     */
    function enterEditorMode() {
        return setEditorMode(true);
    }

    /**
     * [移植] exitEditorMode (クリアボタン用)
     */
    function exitEditorMode(options) {
        return setEditorMode(false, options);
    }

    /**
     * [移植] reset (エラー/クリア用)
     */
    function reset() {
        slideData = [];
        currentIndex = 0;
        
        if (isEditorActive) {
            exitEditorMode({ skipValidation: true });
        }
        isEditorActive = false; 
        
        if (editorContainer) editorContainer.style.display = 'none';
        if (mainTextarea) {
            mainTextarea.value = '';
            mainTextarea.style.display = 'block';
        }


        if (step1Card) step1Card.style.display = 'block';
        if (typeof updatePreviewButtonForEditor === 'function') {
            updatePreviewButtonForEditor(false);
        }
        
        // ステータス表示をリセット (jsonValidation を非表示に戻す)
        const validationDiv = $('jsonValidation');
        if(validationDiv) validationDiv.style.display = 'none';
        
        // ★ 追加: エリア表示復帰後に高さを再調整する
        if (typeof adjustTextAreaHeight === 'function') {
            setTimeout(adjustTextAreaHeight, 0);
        }
    }


    /**
     * [新規追加] 復元・読み込み時にエディタの状態を「更新モード」としてセットアップする
     */
    function setupForUpdate() {
        // 1. 強制的に編集モードに入る
        enterEditorMode();

        // 2. プレビューボタンの状態を強制更新
        if (typeof updatePreviewButtonForEditor === 'function') {
            updatePreviewButtonForEditor(true); // isEditing = true
        }
        
        // 3. 「更新」ボタンを表示するために、UIを更新
        const updateBtn = $('updatePageBtn');
        const generateBtn = $('generateBtn');
        
        if (updateBtn && generateBtn) {
             // 強制的に表示を切り替える
             updateBtn.style.display = 'inline-flex';
             updateBtn.querySelector('.btn-text').textContent = '1ページ目を更新';
             
             // ★★★ [修正] ここでクラスを強制的に Primary (青) に変更する ★★★
             updateBtn.classList.remove('btn-secondary');
             updateBtn.classList.add('btn-primary');
             updateBtn.style.backgroundColor = ''; // インラインスタイルの背景色があればリセット
             
             // 生成ボタンのテキスト変更
             generateBtn.querySelector('.btn-text').textContent = 'スライドを更新'; 
        }
        
        console.log('[SlideEditor] setupForUpdate executed. Editor is now in update mode.');
    }










    /**
     * 3. エディタUIの全体を描画 (クリアボタン追加 + SVGレスポンシブ修正 + 要約ボタン追加 + レスポンシブテキスト対応)
     */
    function renderEditorUI() {
        if (!slideData || slideData.length === 0) {
            editorContainer.innerHTML = '<p>データがありません。</p>';
            return;
        }
        if (currentIndex < 0) currentIndex = 0;
        if (currentIndex >= slideData.length) currentIndex = slideData.length - 1;

        const currentSlide = slideData[currentIndex];
        let slideTypeDisplay = currentSlide.type || 'N/A';
        if (typeof currentSlide.image === 'object' && currentSlide.image !== null && currentSlide.image.chartType) {
            slideTypeDisplay += ` <span style="font-weight: 400; color: var(--text-secondary); font-size: 0.9em;">(${currentSlide.image.chartType})</span>`;
        }

        let navHTML = `
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color);">
                <button id="editorPrevBtn" class="btn-secondary" style="text-transform: none; ${currentIndex === 0 ? 'visibility: hidden;' : ''}" ${currentIndex === 0 ? 'disabled' : ''}>
                    <span class="nav-text-full">< 前のページ</span>
                    <span class="nav-text-short">< 前</span>
                </button>
                <span style="font-size: 0.875rem; text-align: center;">
                    <span class="nav-text-full">スライド ${currentIndex + 1} / ${slideData.length}</span>
                    <span class="nav-text-short">${currentIndex + 1} / ${slideData.length}</span>
                    <strong style="display: block; font-size: 1rem; color: var(--primary-color);">${slideTypeDisplay}</strong>
                </span>

                <div style="display: flex; gap: 0.5rem; align-items: center;">
                    <button id="editorNextBtn" class="btn-secondary" style="text-transform: none; ${currentIndex === slideData.length - 1 ? 'visibility: hidden;' : ''}" ${currentIndex === slideData.length - 1 ? 'disabled' : ''}>
                        <span class="nav-text-full">次のページ ></span>
                        <span class="nav-text-short">次 ></span>
                    </button>
                </div>
            </div>
        `;

        let formCSS = `
            <style>
                /* ★ SVGが本来のサイズで画面を押し広げるのを防ぐ設定 ★ */
                .svg-preview-container svg {
                    max-width: 100% !important;
                    height: auto !important;
                }

                #editorFormArea .form-group-grid { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid #f3f4f6; }
                #editorFormArea .editor-form-label { flex-shrink: 0; font-weight: 500; font-size: 0.8125rem; color: var(--text-secondary); margin-top: 0.5rem; text-align: right; padding-right: 0.75rem; line-height: 1.4; white-space: nowrap; box-sizing: border-box; width: var(--label-width, 120px); }
                #editorFormArea .field-wrapper { flex-grow: 1; min-width: 0; position: relative; display: flex; align-items: flex-start; gap: 0.5rem; }
                #editorFormArea .field-wrapper > *:first-child:not(.btn-delete) { flex-grow: 1; }
                #editorFormArea .field-wrapper textarea { width: 100%; resize: none; overflow: hidden; }
                #editorFormArea .field-wrapper input[type="text"], #editorFormArea .field-wrapper select { width: 100%; }
                #editorFormArea .btn-delete { background: none; border: none; color: var(--text-light); border-radius: 50%; width: 20px; height: 20px; font-size: 1.3rem; line-height: 1; cursor: pointer; padding: 0; font-weight: 400; flex-shrink: 0; margin-top: 0.4rem; }
                #editorFormArea .btn-delete:hover { background: var(--bg-body); color: var(--text-secondary); }
                #editorFormArea fieldset { border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 0.75rem 1rem 1rem 1rem; margin: 0; }
                #editorFormArea fieldset legend { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); padding: 0 0.5rem; margin-left: 0.5rem; }
                #editorFormArea .array-item { border-bottom: 1px dashed var(--border-color); padding-bottom: 0.5rem; margin-bottom: 0.5rem; }
                #editorFormArea .array-item:last-child { border-bottom: none; padding-bottom: 0; margin-bottom: 0; }
                #editorFormArea .array-add-btn { margin-top: 0.75rem; }
                
                /* ▼▼▼ [修正] justify-content を削除 ▼▼▼ */
                .clear-button-wrapper { display: flex;
                    /* justify-content: space-between; */ /* ← これを削除 */
                    align-items: center; margin-top: 1rem; clear: both; }
                
                /* ▼▼▼ [修正] margin-left: auto を追加して常に右寄せにする ▼▼▼ */
                #editorClearBtn {
                    margin-left: auto; /* ← これを追加 */
                    text-transform: none;
                    font-size: 0.875rem; padding: 0.5rem 1rem; width: auto; background-color: var(--secondary-color); color: white;
                }
                #editorClearBtn:hover { background-color: var(--danger-color); color: white; }

                /* ▼▼▼ [追加] レスポンシブテキスト切り替え用CSS ▼▼▼ */
                /* デフォルト: 短縮版を非表示、通常版を表示 */
                .btn-text-short-ver { display: none; }
                .btn-text-full-ver { display: inline; }

                /* 画面幅が600px以下、または narrow-layout クラスがある場合 */
                @media (max-width: 600px) {
                    .btn-text-short-ver { display: inline; }
                    .btn-text-full-ver { display: none; }
                }
                /* 親コンテナが狭い場合の対応 (JS制御クラスと連動) */
                #left-pane.narrow-layout .btn-text-short-ver { display: inline; }
                #left-pane.narrow-layout .btn-text-full-ver { display: none; }
            </style>
        `;
        
        let formHTML = `<div id="editorFormArea" style="padding-top: 0.5rem;"></div>`;
        editorContainer.innerHTML = navHTML + formCSS + formHTML;
        renderSlideEditorForm(currentSlide);




      
        const formArea = $('editorFormArea');
        if (formArea) {
          // ▼▼▼ [修正] ボタン表示制御ロジック ▼▼▼
          
          // 1. 全データの中に要約スライドが存在するかチェック
          const hasSummarySlide = slideData.some(s => s.isSummary === true);
          // 2. 現在のスライドが要約スライドかチェック
          const isCurrentSlideSummary = currentSlide.isSummary === true;

          // 3. ボタンを表示すべきか判定
          // 「要約スライドがまだ無い」 または 「現在見ているのが要約スライド」 の場合のみ表示
          const shouldShowSummaryBtn = !hasSummarySlide || isCurrentSlideSummary;

          let summaryBtnHTML = '';

          if (shouldShowSummaryBtn) {
              const summaryBtnTextFull = isCurrentSlideSummary ? '要約画像を変更' : '要約スライドを追加';
              const summaryBtnTextShort = isCurrentSlideSummary ? '画像変更' : '要約を追加';

              // ボタンのHTMLを生成
              summaryBtnHTML = `
              <button id="editorAddSummaryBtn" type="button" 
                     style="
                        padding: 0.5rem 1.0rem;
                        font-size: 0.8rem;
                        font-weight: 600;
                        color: var(--text-on-primary, #FFFFFF);
                        background-color: rgba(var(--primary-color-rgb, 66, 133, 244), 0.8);
                        backdrop-filter: blur(4px);
                        -webkit-backdrop-filter: blur(4px);
                        border: none;
                        border-radius: 9999px;
                        box-shadow: var(--shadow-sm);
                        cursor: pointer;
                        transition: all 0.2s ease;
                        white-space: nowrap;
                        display: inline-flex;
                        justify-content: center;
                        align-items: center;
                     "
                     onmouseover="this.style.backgroundColor='rgba(var(--primary-color-rgb, 66, 133, 244), 1.0)'"
                     onmouseout="this.style.backgroundColor='rgba(var(--primary-color-rgb, 66, 133, 244), 0.8)'"
                >
                  <span class="btn-text-full-ver">${summaryBtnTextFull}</span>
                  <span class="btn-text-short-ver">${summaryBtnTextShort}</span>
               </button>`;
          }
          // ▲▲▲ [修正] ここまで ▲▲▲

          // ラッパーの中に summaryBtnHTML (空文字またはボタンタグ) を埋め込む
          formArea.innerHTML += `
            <div class="clear-button-wrapper">
              ${summaryBtnHTML}
              <button id="editorClearBtn" class="btn">
                  <span class="btn-text-full-ver">データをクリア</span>
                  <span class="btn-text-short-ver">クリア</span>
              </button>
            </div>
           `;
        }





        optimizeLabelWidths();
        initTextareaAutosize();
        
        // データをクリアボタンの処理
        const clearBtn = $('editorClearBtn');
        if (clearBtn) {
            clearBtn.addEventListener('click', function(event) {
                event.preventDefault();
                showClearConfirmModal(); 
            });
        }

        // 要約スライド追加ボタンの処理
        const addSummaryBtn = $('editorAddSummaryBtn');
        if (addSummaryBtn) {
            addSummaryBtn.addEventListener('click', function(event) {
                event.preventDefault();
                const summaryModal = $('summaryPromptModal');
                const summaryTextarea = $('summaryPromptTextarea');
                
                // 編集モードの最新データをJSON化して取得
                const currentData = SlideEditor.getSlideData();
                const currentJson = JSON.stringify(currentData, null, 2);

                if (!currentJson) {
                    showStatus('データがありません', 'error');
                    return;
                }

                const promptText = 
`以下のJSONデータは、現在作成中のスライド構成です。
このプレゼンテーションの内容全体を総括する「まとめ（Summary）」のインフォグラフィック画像を1枚作成し、
画像データとして出力してください。

【条件】
1. 
2. 
3. 

【現在のスライド構成データ】
${currentJson}`;

                if (summaryTextarea) summaryTextarea.value = promptText;
                if (summaryModal) summaryModal.style.display = 'flex';
            });
        }
    }





    /**
     * 4. 現在のスライドフォーム生成 (修正版: NotebookLM時の表示項目制限・変換後対応)
     */
    function renderSlideEditorForm(slide) {
        const formArea = $('editorFormArea');
        if (!formArea) return;
        let html = '';

        // ▼▼▼ [修正] NotebookLM (PDFページ指定) かどうかの判定ロジック拡張 ▼▼▼
        // ヘルパー: 値が NotebookLM 形式の文字列かどうかを判定
        const checkNotebookLmFormat = (val) => (typeof val === 'string' && /^NotebookLM\d+$/i.test(val.trim()));

        // 判定: 現在の image 値、または バックアップされた _originalImage 値のいずれかが該当すればOK
        const isNotebookLmPlaceholder = checkNotebookLmFormat(slide.image) || checkNotebookLmFormat(slide._originalImage);

        let fieldOrder = [];

        // ▼▼▼ [修正] splitImage タイプも表示項目を限定する ▼▼▼
        if (isNotebookLmPlaceholder || slide.type === 'splitImage') {
            // ★ NotebookLMやsplitImageの場合はこの3項目のみ表示
            fieldOrder = ['title', 'image', 'notes'];
        } else {
            // ★ 通常時は全項目を表示候補にする
            fieldOrder = [
                'type',          // (通常は非表示)
                'title',         // タイトル
                'sectionNo',     // セクション番号
                'subhead',       // サブヘッド
                'date',          // 日付
               
                // --- レイアウト設定 ---
                'imagePosition', // 画像位置

                // --- メインテキストコンテンツ ---
                'centerText',    // 中央テキスト
                'points',        // 箇条書き
                'items',         // 項目リスト
                'steps',         // ステップ
                'milestones',    // マイルストーン
                'stats',         // 統計データ
                'headers',       // ヘッダー
                'rows',          // 行データ
                'columns',       // カラム
                'lanes',         // レーン
                'levels',        // レベル
                'flows',         // フロー
                'leftTitle',     // 左タイトル
                'leftItems',     // 左項目
                'rightTitle',    // 右タイトル
                'rightItems',    // 右項目

               // --- 画像・ビジュアル要素 ---
                'image',         // 画像/グラフ
                'imageCaption',  // キャプション
                
                // --- その他 ---
                'showTrends',    // 傾向表示
                'notes'          // スピーカーノート
            ];
        }
        // ▲▲▲ [修正] ここまで ▲▲▲

        // 1. 定義された順序に従ってフィールドを描画
        fieldOrder.forEach(key => {
            if (slide.hasOwnProperty(key)) {
                html += createFormField(slide, key, slide[key]);
            }
        });

        // 2. 定義に含まれていないその他のキーを末尾に追加 (通常時のみ)
        if (!isNotebookLmPlaceholder && slide.type !== 'splitImage') {
            Object.keys(slide).forEach(key => {
                if (!fieldOrder.includes(key)) {
                    html += createFormField(slide, key, slide[key]);
                }
            });
        }

        formArea.innerHTML = html;
    }




    /**
     * 5. フォームフィールドHTML生成 (修正版: originalData優先プレビュー対応)
     */
    function createFormField(parentObject, key, value, idPrefix = 'editor_field_') {
         
         // ▼▼▼ ラベル日本語化マップ ▼▼▼
         const labelMap = {
            title: 'タイトル', date: '日付', notes: 'スピーカーノート', subhead: 'サブヘッド', sectionNo: 'セクション番号',
            points: '箇条書き', columns: 'カラム', items: '項目', leftTitle: '左タイトル', rightTitle: '右タイトル',
            leftItems: '左の項目', rightItems: '右の項目', steps: 'ステップ', milestones: 'マイルストーン', label: 'ラベル',
            state: '状態', lanes: 'レーン', centerText: '中央テキスト', headers: 'ヘッダー', rows: '行',
            percent: 'パーセント', text: 'テキスト', author: '引用元', value: '値', change: '変化',
            status: 'ステータス', q: '質問', a: '回答', stats: '統計', leftValue: '左の値',
            rightValue: '右の値', trend: '傾向', showTrends: '傾向表示', levels: 'レベル', description: '説明',
            desc: '詳細', flows: 'フロー', image: '画像/グラフ', backgroundImage: '背景画像', foregroundImage: '合成画像', ghostImageBase64: 'ゴースト番号画像',
            imageCaption: '画像キャプション', imagePosition: '画像の位置',
            chartType: 'グラフ種別', data: 'データ', subtitle: 'サブタイトル', source: '出典', yAxisUnitLabel: 'Y軸単位',
            xAxisLabels: 'X軸ラベル', series: '系列', values: '値 (配列)', colors: '色設定', color: '色 (単一/棒)', 
            line: '線の色', id: 'ID', start: '開始色', end: '終了色', centerLabel: '中央ラベル',
            legendLabels: '凡例ラベル', barData: '棒データ', barValue: '棒の値', lineValue: '線の値',
            legendBarLabel: '棒の凡例', legendLineLabel: '線の凡例', yAxisLeftLabel: '左Y軸ラベル', yAxisRightLabel: '右Y軸ラベル'
         };

         const fieldId = `${idPrefix}${key}`;
         const currentSlideType = (typeof slideData !== 'undefined' && slideData[currentIndex]) ? slideData[currentIndex].type : '';
         const isGraphDataArray = idPrefix.startsWith('editor_field_image_data_');
         const isArrayPrimitive = idPrefix.endsWith('_') && /^\d+$/.test(key);

         // 非表示にするキー
         if (String(key).startsWith('_') ||
            (key === 'type' && idPrefix === 'editor_field_') ||
            (key === 'twoColumn') ||
            (key === 'isSummary') ||
            (key === 'ghostImageBase64') ||
            (key === 'columns' && currentSlideType === 'cards') ||
            (idPrefix.startsWith('editor_field_image_data_') && ['layout', 'yAxis', 'barOptions', 'lineOptions', 'yAxisLeft', 'yAxisRight'].includes(key)) ||
            (idPrefix === 'editor_field_image_' && ['chartType', 'layers', 'originalWidth', 'originalHeight', 'originalData'].includes(key))) {
            return '';
         }

         let labelText;
         if (isArrayPrimitive) labelText = `${parseInt(key, 10) + 1}`;
         else labelText = labelMap[key] || key;
         
         if (idPrefix.startsWith('editor_field_image_data_barData_') && key === 'label') labelText = 'X軸項目';
         if (idPrefix.startsWith('editor_field_image_data_items_') && key === 'label') labelText = '凡例項目';

        let fieldHTML = '';
        let helpTextHTML = '';
        const onInput = `oninput="SlideEditor.autoResizeTextarea(this)"`;

        // ▼▼▼【修正】画像プレビュー判定（originalData優先） ▼▼▼
        const isImageKey = ['image', 'backgroundImage', 'foregroundImage'].includes(key);
        let previewBase64 = null;

        if (isImageKey) {
            if (typeof value === 'string' && value.trim().startsWith('data:image/')) {
                previewBase64 = value;
            } else if (typeof value === 'object' && value !== null) {
                // ★修正: originalData があればそれを優先してプレビューに使用（分解前の画像を表示）
                if (value.originalData && typeof value.originalData === 'string' && value.originalData.trim().startsWith('data:image/')) {
                    previewBase64 = value.originalData;
                } 
                // なければ data プロパティを使用
                else if (value.data && typeof value.data === 'string' && value.data.trim().startsWith('data:image/')) {
                    previewBase64 = value.data;
                }
            }
        }

        if (previewBase64) {
             const textValue = (typeof value === 'object') ? JSON.stringify(value) : value;
             
             // ★追加: キャッシュ確認
             // (現在のスライドインデックスとキーを使って問い合わせる)
             const currentIndex = SlideEditor.getCurrentIndex(); // 現在位置を取得
             const hasCache = (typeof ImageSplitterTool !== 'undefined' && ImageSplitterTool.hasCache(currentIndex, key));
             
             // ▼▼▼ [変更] ステータスアイコンのロジック (赤/緑の丸に変更) ▼▼▼
             // 未処理(false)なら赤、処理済み(true)なら緑
             const statusColor = hasCache ? '#10B981' : '#EF4444'; 
             const statusTitle = hasCache ? '解析済み' : '未解析';
             
             const statusIcon = `<span style="
                position: absolute; 
                top: 8px; 
                right: 8px; 
                width: 14px; 
                height: 14px; 
                background-color: ${statusColor}; 
                border: 2px solid #fff; 
                border-radius: 50%; 
                box-shadow: 0 2px 4px rgba(0,0,0,0.3);
                z-index: 10;
             " title="${statusTitle}"></span>`;
             // ▲▲▲ [変更] ここまで ▲▲▲

             // ▼▼▼ [変更] ボタンのデザインと配置 ▼▼▼
             // fieldHTMLの生成
             fieldHTML = `<div style="width: 100%; position: relative;">
                   <textarea id="${fieldId}" class="form-control" rows="1" ${onInput} style="display: none;">${escapeHTML(textValue)}</textarea>
                   
                   <div class="image-preview-wrapper" 
                        style="margin-top: 0.5rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background-color: #fff; padding: 10px; text-align: center; position: relative;">
                       
                       ${statusIcon} 
                       
                       <img src="${previewBase64}" 
                            style="max-width: 100%; max-height: 300px; object-fit: contain; box-shadow: 0 2px 4px rgba(0,0,0,0.1); cursor: pointer;"
                            alt="Slide Image Preview"
                            title="クリックして画像を分解・編集"
                            onclick="ImageSplitterTool.open(this.src, ${currentIndex}, '${key}')">
                        
                       <div style="margin-top: 10px;">
                         <button type="button" 
                            onclick="ImageSplitterTool.open('${previewBase64}', ${currentIndex}, '${key}')"
                            style="
                              display: inline-flex;
                              align-items: center;
                              justify-content: center;
                              background-color: #333;
                              color: #fff;
                              border: 1px solid rgba(255,255,255,0.2);
                              border-radius: 30px;
                              padding: 0.4rem 1.0rem;
                              font-family: var(--font-family-base);
                              font-size: 0.8rem;
                              font-weight: 600;
                              cursor: pointer;
                              box-shadow: 0 2px 4px rgba(0,0,0,0.2);
                              transition: all 0.2s ease;
                            "
                            onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 4px 6px rgba(0,0,0,0.3)'"
                            onmouseout="this.style.transform='none'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.2)'"
                         >
                           Nano Banana Editor
                         </button>
                       </div>
                   </div>
                 </div>`;
             // ▲▲▲ [変更] ここまで ▲▲▲
        }
        else if (Array.isArray(value)) {
            let arrayItemsHTML = '';
            const isColumns = key === 'columns';
            const isLanesArrayItself = idPrefix === 'editor_field_' && key === 'lanes';
            const isCardsItems = currentSlideType === 'cards' && key === 'items';
            // ★ object配列項目の個別削除(×)を許可する配列か。content 2カラム(配列の配列)とグラフdataは
            //   構造保護のため除外。それ以外(kpi/cards/timeline/stats/levels/lanes 等)は画面から項目削除可能にする。
            const allowItemDelete = !isColumns && !isGraphDataArray;
            value.forEach((item, index) => {
                const itemPrefix = `${fieldId}_${index}_`;
                let itemFieldsHTML = '';
                let itemDeleteBtn = '';
                if (typeof item === 'object' && item !== null) {
                    Object.keys(item).forEach(subKey => {
                        itemFieldsHTML += createFormField(item, subKey, item[subKey], itemPrefix);
                    });
                    // ★ object配列項目ごとの削除(×)ボタン(primitive項目は各リーフが既に×を持つ)。
                    //   data-path=`${fieldId}_${index}` は既存の削除ハンドラ(末尾indexをpop→splice)と同契約。
                    if (allowItemDelete) {
                        itemDeleteBtn = `<button class="btn-delete array-item-delete" data-action="delete" data-path="${fieldId}_${index}" title="この項目を削除" style="position:absolute; top:6px; right:6px; z-index:2;">×</button>`;
                    }
                } else {
                    itemFieldsHTML += createFormField(value, index, item, `${fieldId}_`);
                }
                const itemStyle = itemDeleteBtn ? ' style="position:relative; padding-right:1.5rem;"' : '';
                arrayItemsHTML += `<div class="array-item"${itemStyle}>${itemDeleteBtn}${itemFieldsHTML}</div>`;
            });
            let addButtonHTML = '';
            if (!isColumns && !isLanesArrayItself && !isCardsItems && !isGraphDataArray) {
                addButtonHTML = `<button class="btn-preset array-add-btn" data-action="add" data-path="${fieldId}">+ 新規項目を追加</button>`;
            }
            fieldHTML = `<fieldset>${arrayItemsHTML}${addButtonHTML}</fieldset>`;
        } else if (typeof value === 'object' && value !== null) {
            let objectItemsHTML = '';
            let legendHTML = '';
            if (idPrefix === 'editor_field_image_' && key === 'data') {
                let chartName = "データ";
                if (parentObject.chartType) {
                    // (チャートタイプ判定ロジック省略 - 変更なし)
                    chartName = parentObject.chartType;
                }
                legendHTML = `<legend>${chartName} の詳細</legend>`;
            }
            Object.keys(value).forEach(subKey => {
                objectItemsHTML += createFormField(value, subKey, value[subKey], `${fieldId}_`);
            });
            if (idPrefix === 'editor_field_' && key === 'image') {
                fieldHTML = objectItemsHTML;
            } else {
                fieldHTML = `<fieldset>${legendHTML}${objectItemsHTML}</fieldset>`;
            }
        } else {
            // (プリミティブ値の処理)
            // ★ 任意キーの Base64画像データ(data:image/...)は生テキストで出さず、サムネイル＋非表示textarea(値は保持)で表示。
            //   section の ghostImageBase64 等、画像キー(image/backgroundImage/foregroundImage)以外に入った
            //   data URL が編集画面に巨大なBase64文字列として表示されるのを防ぐ。
            if (typeof value === 'string' && value.trim().startsWith('data:image/')) {
                fieldHTML = `<div style="width: 100%;">
                   <textarea id="${fieldId}" class="form-control" rows="1" style="display: none;">${escapeHTML(value)}</textarea>
                   <div style="margin-top: 0.25rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background-color: #fff; padding: 8px; text-align: center;">
                     <img src="${value.trim()}" style="max-width: 100%; max-height: 140px; object-fit: contain;" alt="画像データ">
                     <div style="margin-top: 4px; font-size: 0.72rem; color: var(--text-secondary, #6b7280);">画像データ（Base64・自動保持）</div>
                   </div>
                 </div>`;
            }
            else if (key === 'image' && typeof value === 'string' && value.trim().toLowerCase().startsWith('<svg')) {
                fieldHTML = `<div style="width: 100%;">
                  <textarea id="${fieldId}" class="form-control" rows="6" ${onInput} style="display: none;">${escapeHTML(value)}</textarea>
                   <div class="svg-preview-container" 
                        onclick="SvgVisualEditor.open(document.getElementById('${fieldId}').value, (newCode) => { 
                            const ta = document.getElementById('${fieldId}'); 
                            ta.value = newCode; 
                            ta.dispatchEvent(new Event('input', { bubbles: true })); 
                          })"
                        title="クリックしてSVGをビジュアル編集"
                        style="margin-top: 0.5rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background-color: #fff; padding: 10px; text-align: center; overflow: auto; min-height: 50px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s;">
                       ${value}
                   </div>
                 </div>`;
            }
            else if (key === 'id' && idPrefix.includes('editor_field_image_data_')) {
                 fieldHTML = `<input type="text" id="${fieldId}" class="form-control" value="${escapeHTML(value)}" readonly style="background-color: var(--bg-body); color: var(--text-secondary); cursor: not-allowed;">`;
            } else if (key === 'imagePosition') {
                fieldHTML = `<select id="${fieldId}" class="form-control">
                               <option value="left" ${value === 'left' ? 'selected' : ''}>左 (left)</option>
                               <option value="right" ${value === 'right' ? 'selected' : ''}>右 (right)</option>
                             </select>`;
            } else if (typeof value === 'boolean') {
                 fieldHTML = `<div class="checkbox-wrapper" style="padding: 0.5rem 0;">
                               <input type="checkbox" id="${fieldId}" ${value ? 'checked' : ''} style="width: 16px; height: 16px;">
                               <label for="${fieldId}" style="margin-bottom: 0; font-size: 0.875rem;">${labelText}を有効にする</label>
                             </div>`;
            } else if (isColorString(value)) {
                fieldHTML = `<div style="position: relative; display: flex; align-items: center; width: 100%;">
                               <input type="color" id="${fieldId}_picker" value="${escapeHTML(value)}" style="width: 30px; height: 30px; padding: 0; border: 1px solid var(--border-color); border-radius: var(--radius-sm); margin-right: 0.5rem; cursor: pointer; flex-shrink: 0;">
                               <input type="text" id="${fieldId}" class="form-control" value="${escapeHTML(value)}" style="flex-grow: 1;">
                             </div>`;
            } else if (isTextareaField(key, value, idPrefix)) {
                fieldHTML = `<textarea id="${fieldId}" class="form-control" rows="1" ${onInput} style="font-family: var(--font-family-base); font-size: 0.875rem;">${escapeHTML(value)}</textarea>`;
            } else {
                fieldHTML = `<input type="text" id="${fieldId}" class="form-control" value="${escapeHTML(value)}">`;
            }
        }

        if (!previewBase64 && (Array.isArray(value) || (typeof value === 'object' && value !== null))) {
            if (key === 'image' || (idPrefix === 'editor_field_image_' && key === 'data')) {
                return fieldHTML;
            }
            return `<div class="form-group-grid" style="align-items: stretch;">
                        <label class="editor-form-label" style="margin-top: 0.5rem;">${labelText}</label>
                        <div class="field-wrapper">${fieldHTML}${helpTextHTML}</div>
                     </div>`;
        }

        let labelMarginTop = '0.5rem';
        if (typeof value === 'boolean') labelMarginTop = '0.6rem';
        else if (fieldHTML.startsWith('<textarea')) labelMarginTop = '0.4rem';
        else if (isColorString(value)) labelMarginTop = '0.4rem';
        
        let deleteButtonHTML = '';
        if (isArrayPrimitive && !isGraphDataArray) {
            const deletePath = idPrefix + key;
            deleteButtonHTML = `<button class="btn-delete" data-action="delete" data-path="${deletePath}" title="項目を削除">×</button>`;
        }

        if (typeof value === 'boolean') {
             return `<div class="form-group-grid">
                        <label class="editor-form-label" style="margin-top: ${labelMarginTop};"></label> <div class="field-wrapper">${fieldHTML}${deleteButtonHTML}</div>
                     </div>`;
        }

        return `<div class="form-group-grid">
                    <label for="${fieldId}" class="editor-form-label" style="margin-top: ${labelMarginTop};">${labelText}</label>
                    <div class="field-wrapper">${fieldHTML}${deleteButtonHTML}</div>
                    ${helpTextHTML ? `<div style="padding-left: calc(var(--label-width, 120px) + 0.75rem);">${helpTextHTML}</div>` : ''}
                </div>`;
    }


    /**
     * (Helper) フォームIDパスからデータ更新 (変更なし)
     */
    function updateDataFromField(field) {
        try {
            const fieldIdToUse = field.id.replace('_picker', '');
            const path = fieldIdToUse.replace('editor_field_', '').split('_');
            const { parent, lastKey } = getObjectFromPath(path);
            const originalValue = parent[lastKey];
            let newValue;
            if (field.type === 'checkbox') {
                newValue = field.checked;
            } else if (field.type === 'textarea') {
                if (Array.isArray(originalValue) && (originalValue.length === 0 || typeof originalValue[0] === 'string' || typeof originalValue[0] === 'number')) {
                    const lines = field.value.split('\n').filter(line => line.trim() !== '');
                    if (typeof originalValue[0] === 'number') {
                        newValue = lines.map(line => parseFloat(line)).filter(num => !isNaN(num));
                    } else {
                        newValue = lines;
                    }
                } else if (Array.isArray(originalValue) || (typeof originalValue === 'object' && originalValue !== null)) {
                    try {
                        newValue = JSON.parse(field.value);
                        field.style.borderColor = 'var(--border-color)';
                    } catch (e) {
                        field.style.borderColor = 'var(--danger-color)';
                        return;
                    }
                } else {
                    newValue = field.value;
                }
            } else if (field.type === 'select-one') {
                newValue = field.value;
            } else { // text, color
                if (typeof originalValue === 'number' && !isNaN(field.value)) {
                    newValue = parseFloat(field.value);
                } else {
                    newValue = field.value;
                }
            }
            parent[lastKey] = newValue;
        } catch (e) {
            console.warn("Failed to update data from field:", field.id, e);
        }
    }

    /**
     * (Helper) パスからオブジェクト取得 (変更なし)
     */
    function getObjectFromPath(path) {
         let current = slideData[currentIndex];
        for (let i = 0; i < path.length - 1; i++) {
            if (typeof current === 'undefined' || current === null) throw new Error(`Invalid path segment: ${path[i]} in path ${path.join('_')}`);
            current = current[path[i]];
        }
        if (typeof current === 'undefined' || current === null) throw new Error(`Invalid path parent for key: ${path[path.length - 1]} in path ${path.join('_')}`);
        return { parent: current, lastKey: path[path.length - 1] };
    }

    /**
     * (Helper) 新規アイテム形状推測 (変更なし)
     */
    function getNewItemShape(key, array) {
         if (array.length > 0) {
            const firstItem = array[0];
            if (typeof firstItem === 'object' && firstItem !== null) {
                try {
                    const template = JSON.parse(JSON.stringify(firstItem));
                    Object.keys(template).forEach(k => {
                        if (Array.isArray(template[k])) template[k] = [];
                        else if (typeof template[k] === 'object' && template[k] !== null) template[k] = {};
                        else template[k] = '';
                    });
                    return template;
                } catch(e) {}
            } else if (typeof firstItem === 'string' || typeof firstItem === 'number') {
                return (typeof firstItem === 'number') ? 0 : '';
            }
        }
        switch(key) {
            case 'items': case 'milestones': case 'lanes': case 'stats': case 'levels': case 'flows':
                if (key === 'lanes') return { title: 'New Lane', items: [] };
                if (slideData[currentIndex].type === 'statsCompare') return { label: '', leftValue: '', rightValue: '' };
                return { title: '', desc: '' };
            case 'series': return { id: 'New', label: 'New Series', values: [] };
            case 'barData': return { label: 'New', values: [] };
            case 'colors': return { id: 'New', start: '#FFFFFF', end: '#000000' };
            case 'headers': case 'rows': case 'points': case 'steps': case 'leftItems': case 'rightItems': case 'xAxisLabels':
                return ''; // 文字列配列
            case 'values': return 0; // 数値配列の可能性
            default:
                if(array && array.length > 0 && typeof array[0] === 'string') return '';
                if(array && array.length > 0 && typeof array[0] === 'number') return 0;
                return {};
        }
    }

    /**
     * 7. [削除] updateEditorToggle 関数
     */
    // function updateEditorToggle(isValid, slideCount) { ... } // ◀ [削除]

    /**
     * (Helper) updatePreviewButtonForEditor (無題.html の既存関数を流用)
     */
    // [変更] updatePreviewButtonForEditor を SlideEditor のローカル関数に移動
    function updatePreviewButtonForEditor(isEditing) {
        const previewToggleButton = $('showLastSlideBtn');
const updatePageBtn = $('updatePageBtn');
        const generateBtn = $('generateBtn');
        const showPreviewBtnLeftContainer = $('showPreviewBtnLeftContainer');
        // ★[要望2] JSON編集画面(エディタ)がアクティブな間は、右上のアップロードボタン(📤)を隠し、履歴(🕒)だけ残す
        const uploadFileBtn = document.getElementById('uploadFileBtn');
        if (uploadFileBtn) uploadFileBtn.style.display = isEditing ? 'none' : '';
if (!updatePageBtn || !generateBtn || !previewToggleButton || !showPreviewBtnLeftContainer) return;
        
        if (isEditing) {
            // --- 編集モード ---
            if (isPreviewVisible) {
                // ▼ プレビュー表示中 ▼
                // ★[要望3] 初回スライド生成が完全に完了するまでは「○ページ目を更新」ボタンを出さない(生成中の違和感を解消)
                if (lastGeneratedSlideUrl && !window.isInitialGenerating) {
updatePageBtn.querySelector('.btn-text').textContent = `${currentIndex + 1}ページ目を更新`;
                  updatePageBtn.onclick = updateCurrentPage; 
                  updatePageBtn.style.display = 'inline-flex';
                  updatePageBtn.style.backgroundColor = ''; 
                  updatePageBtn.classList.remove('btn-secondary');
updatePageBtn.classList.add('btn-primary');
                } else {
                  updatePageBtn.style.display = 'none';
}
                previewToggleButton.style.display = 'none';
showPreviewBtnLeftContainer.style.display = 'none';
            } else {
                // ▼ プレビュー非表示中 ▼
                updatePageBtn.style.display = 'none';

                // ▼▼▼ [修正] lastGeneratedSlideUrl がある場合のみ「プレビューを表示」ボタンを表示 ▼▼▼
                if (lastGeneratedSlideUrl) {
                    previewToggleButton.style.display = 'inline-flex';
previewToggleButton.textContent = 'プレビューを表示';
                    previewToggleButton.onclick = function() { 
                        if (lastGeneratedSlideUrl) {
showPreviewPane();
                            updatePreviewButtonForEditor(true);
                        } else {
showStatus('エラー: 表示するスライドがありません。', 'error');
                        }
                    };
previewToggleButton.classList.add('btn-primary');
                    previewToggleButton.classList.remove('btn-secondary');
previewToggleButton.style.backgroundColor = '';
                    showPreviewBtnLeftContainer.style.display = 'block';
} else {
                    previewToggleButton.style.display = 'none'; // ★ 隠す
showPreviewBtnLeftContainer.style.display = 'none';
                }
                // ▲▲▲ [修正] ここまで ▲▲▲
            }

            // 「プレゼンテーションを生成」ボタンは常に表示
            generateBtn.style.backgroundColor = '';
generateBtn.classList.remove('btn-secondary');
            generateBtn.classList.add('btn-primary');
            generateBtn.onmouseover = null;
            generateBtn.onmouseout = null;
            generateBtn.style.display = 'inline-flex';
} else {
            // --- テキストモード ---
            updatePageBtn.style.display = 'none';
showPreviewBtnLeftContainer.style.display = 'none';
            generateBtn.style.display = 'none';

            // ▼▼▼ [修正] lastGeneratedSlideUrl がある場合のみ「スライドを表示」ボタンを表示 ▼▼▼
            if (lastGeneratedSlideUrl) {
                previewToggleButton.style.display = 'inline-flex';
previewToggleButton.textContent = isPreviewVisible ? 'プレビューを閉じる' : 'スライドを表示';
                previewToggleButton.onclick = function() { if (isPreviewVisible) hidePreviewPane(); else showPreviewPane(); };
if (!isPreviewVisible) {
                    previewToggleButton.classList.add('btn-primary');
                    previewToggleButton.classList.remove('btn-secondary');
previewToggleButton.style.backgroundColor = '';
                } else {
                    previewToggleButton.classList.remove('btn-primary');
previewToggleButton.classList.add('btn-secondary');
                    previewToggleButton.style.backgroundColor = 'var(--secondary-color)';
                }
            } else {
                previewToggleButton.style.display = 'none'; // ★ 隠す
            }
            // ▲▲▲ [修正] ここまで ▲▲▲
}
    }


    // --- ヘルパー関数 (変更なし) ---
    function autoResizeTextarea(element) {
        if (!element || !element.style) return;
        element.style.height = 'auto';
        element.style.height = (element.scrollHeight) + 'px';
    }
    function initTextareaAutosize() {
        if (!editorContainer) return;
        const textareas = editorContainer.querySelectorAll('#editorFormArea textarea');
        textareas.forEach(autoResizeTextarea);
    }
    function optimizeLabelWidths() {
        if (!editorContainer) return;
        const containers = editorContainer.querySelectorAll('#editorFormArea, #editorFormArea fieldset, #editorFormArea .array-item');
        containers.forEach(container => {
            const labels = container.querySelectorAll(':scope > .form-group-grid > .editor-form-label');
            if (labels.length === 0) return;
            let maxWidth = 0;
            labels.forEach(label => {
                label.style.width = 'auto';
                const rect = label.getBoundingClientRect();
                maxWidth = Math.max(maxWidth, rect.width);
            });
            const finalWidth = Math.max(40, Math.min(maxWidth + 5, 160));
            container.style.setProperty('--label-width', finalWidth + 'px'); // Set CSS variable on container
            labels.forEach(label => {
                label.style.width = finalWidth + 'px';
            });
        });
        const helpTexts = editorContainer.querySelectorAll('.form-group-grid > div[style*="padding-left"]');
        helpTexts.forEach(div => {
            const parentGrid = div.closest('.form-group-grid');
            if (parentGrid) {
                const label = parentGrid.querySelector('.editor-form-label');
                if (label) {
                    const labelWidth = parseFloat(getComputedStyle(label).width || '120'); 
                    div.style.paddingLeft = `calc(${labelWidth}px + 0.75rem)`;
                }
            }
        });
    }
    // ★[C3] SlideEditorローカルのescapeHTMLはグローバル定義(下方 renderHistoryList付近)に統合。
    //   このIIFE内の呼び出しはスコープチェーンでグローバル版に解決される(全呼び出しは`${...}`内で文字列強制)。
    function isColorString(str) {
        if (typeof str !== 'string') return false;
        return /^#([0-9A-F]{3}){1,2}$/i.test(str);
    }
    function isTextareaField(key, value, idPrefix) {
         const textareaKeys = ['notes', 'desc', 'text', 'a', 'q', 'imageCaption', 'author', 'subtitle', 'source', 'yAxisUnitLabel', 'centerLabel', 'legendBarLabel', 'legendLineLabel', 'yAxisLeftLabel', 'yAxisRightLabel', 'title', 'subhead', 'date', 'label'];
        if (idPrefix.endsWith('_') && (typeof value === 'string' || typeof value === 'number')) return true;
        return textareaKeys.includes(key);
    }

    // 公開API
    return {
        init: init,
        // updateEditorToggle: updateEditorToggle, // ◀ [削除]
        autoResizeTextarea: autoResizeTextarea,
        getCurrentIndex: function() { return currentIndex; },
        getSlideData: function() { return slideData; },
        isEditorActive: function() { return isEditorActive; },
        enterEditorMode: enterEditorMode, // ◀ [追加]
        exitEditorMode: exitEditorMode,   // ◀ [追加]
        reset: reset,                 // ◀ [追加]
        updatePreviewButtonForEditor: updatePreviewButtonForEditor, // ◀ [追加] (performClearで使うため)
        setupForUpdate: setupForUpdate,
        renderEditorUI: renderEditorUI
    };

    
})();
// ★★★ [差し替え] ここまで ★★★

    
    // (suppressEditorAutoActivation フラグをグローバルに追加)
    var suppressEditorAutoActivation = false; // エディタ自動起動を一時的に抑制するフラグ

    function showJsonErrorModal(errorMessage) {
      const modal = $('jsonErrorModal');
      const errorContent = $('jsonErrorContent');
      
      if (!modal || !errorContent) return;
      
      // Gemini向けの指示文を追加
      const fullMessage = `以下のJSONデータにエラーがあります。\n修正した完全なJSONコードを出力してください。\n\n【エラー内容】\n${errorMessage}`;
      errorContent.textContent = fullMessage;
      
      modal.style.display = 'flex';
    }
    
    function closeJsonErrorModal() {
      const modal = $('jsonErrorModal');
      if (modal) modal.style.display = 'none';
      
      // SlideEditorを完全にリセット（データ、UI、状態すべて）
      if (SlideEditor && SlideEditor.reset) {
        SlideEditor.reset();
      }
    }
    
    function copyJsonError() {
      const errorContent = $('jsonErrorContent');
      if (!errorContent) return;
      
      const text = errorContent.textContent;
      navigator.clipboard.writeText(text).then(() => {
        showStatus('エラー内容をコピーしました', 'success');
      }).catch(err => {
        console.error('コピーに失敗しました:', err);
        showStatus('コピーに失敗しました', 'error');
      });
    }
    
    function showClearConfirmModal() {
      const modal = $('clearConfirmModal');
      if (modal) modal.style.display = 'flex';
    }
    
    function closeClearConfirmModal() {
      const modal = $('clearConfirmModal');
      if (modal) modal.style.display = 'none';
    }

    /* ▼▼▼ [新規追加] 項目削除モーダルのためのJS関数 ▼▼▼ */
    
    /**
     * 項目削除モーダルを表示し、確認ボタンのコールバックを設定します。
     * @param {function} onConfirmCallback - 「削除する」ボタンが押されたときに実行する関数
     */
    function showDeleteItemConfirmModal(onConfirmCallback) {
      const modal = $('deleteItemConfirmModal');
      const confirmBtn = $('confirmDeleteItemBtn');
      if (!modal || !confirmBtn) return;

      // 1. 既存のリスナーを削除するため、ボタンをクローンして置き換える
      // (古いコールバックが残るのを防ぐ)
      const newConfirmBtn = confirmBtn.cloneNode(true);
      confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);

      // 2. 新しい確認コールバックを一度だけ設定する
      newConfirmBtn.addEventListener('click', () => {
        closeDeleteItemConfirmModal(); // モーダルを閉じる
        if (onConfirmCallback) {
          onConfirmCallback(); // 渡された削除ロジックを実行
        }
      });
      
      modal.style.display = 'flex'; // モーダルを表示
    }

    /**
     * 項目削除モーダルを閉じます。
     */
    function closeDeleteItemConfirmModal() {
      const modal = $('deleteItemConfirmModal');
      if (modal) modal.style.display = 'none';
    }

// ★★★ [差し替え] performClear 関数 (resetを呼ぶように修正) ★★★
    function performClear() {
      
      // [変更] SlideEditor.reset() を呼び出すように統一します。
      // reset() が内部データのリセット、テキストエリアのクリア、UIのテキストモード移行をすべて行います。
      if (SlideEditor && SlideEditor.reset) {
        SlideEditor.reset();
      } else {
        // reset が万が一見つからない場合のフォールバック
        const mainTextarea = $('slideDataInput');
        if (mainTextarea) {
          mainTextarea.value = '';
        }
      }
      
      // モーダルを閉じる
      closeClearConfirmModal();
      
      // フォーカスを戻す
      const mainTextarea = $('slideDataInput');
      if (mainTextarea) mainTextarea.focus();
      
      showStatus('編集内容をクリアしました', 'success');
    }
// ★★★ [差し替え] ここまで ★★★



    // ▼▼▼ 有効なスライドタイプの定義（サーバー側の slideGenerators と一致させる） ▼▼▼
    const VALID_SLIDE_TYPES = [
      "title", "section", "content", "agenda", "compare", 
      "process", "processList", "timeline", "diagram", "cycle", 
      "cards", "headerCards", "table", "progress", "quote", 
      "kpi", "closing", "bulletCards", "faq", "statsCompare", 
      "barCompare", "triangle", "pyramid", "flowChart", "stepUp", 
      "imageText", "fullImage",
      "integratedTitle", "integratedContent",
      "splitImage" // ★追加: 自動分割用スライドタイプ
    ];

    // 配列・オブジェクトの最後の要素に残ったカンマ（末尾カンマ）を取り除く。
    // Gemini がときどき出力するもので、JSON としては不正なため読み込みが止まる。
    // 文字列の中身は変えないよう、エスケープを見ながら1文字ずつ走査する。
    // ===== まじん式スライド: 構成データ(JSON) 自動修復 =====
    // Gemini(特に Flash-Lite)が出す「惜しい JSON」を、寛容なトークナイザで読み直して正しい JSON に組み直す。
    // 直せる壊れ方: 末尾カンマ / 要素間のカンマ抜け / キーの引用符抜け / 配列内オブジェクトの { } 抜け /
    //              文字列内の生改行 / 途中で ```json を書き直した生成崩れ / コードフェンスや先頭の "JSON" ラベル
    // 戻り値: { ok:true, data, text, fixes:[...] } または { ok:false, error }
    function repairSlideDataJson(rawText) {
      const fixes = [];
      if (typeof rawText !== 'string') return { ok: false, error: '文字列ではありません' };
      let text = rawText;

      // 0) まず素直にパースできるならそのまま
      try {
        const d = JSON.parse(text);
        return { ok: true, data: d, text: text, fixes: fixes, unchanged: true };
      } catch (e) { /* 続行 */ }

      // 1) 引用マーカー([cite_start] 等)の除去
      const t1 = text.replace(/\[cite_start\]/g, '').replace(/\[cite:[^\]]*\]/g, '');
      if (t1 !== text) { fixes.push('引用マーカー除去'); text = t1; }

      // 2) 途中で書き直した生成崩れ: ```json が複数あれば「最後の ```json 以降」を採用
      const fenceIdx = [];
      const fenceRe = /```\s*json/gi; let m;
      while ((m = fenceRe.exec(text)) !== null) fenceIdx.push(m.index);
      const candidates = [];
      if (fenceIdx.length >= 2) {
        candidates.push({ text: text.slice(fenceIdx[fenceIdx.length - 1]), note: '書き直し後のブロックを採用' });
      }
      candidates.push({ text: text, note: null });

      for (const cand of candidates) {
        let s = cand.text;
        // 途中で ```json から書き直された出力か（最初の [ より後に ```json がある）を、フェンスを剥がす前に判定する
        const __firstBracket = s.indexOf('[');
        const restartInside = __firstBracket >= 0 && /```\s*json/i.test(s.slice(__firstBracket + 1));
        // 3) コードフェンス / 先頭の JSON ラベルを剥がし、最初の [ から最後の ] までを取り出す
        s = s.replace(/```\s*json/gi, '').replace(/```/g, '');
        s = s.replace(/^\s*JSON\s*\n/i, '');
        const a = s.indexOf('['), b = s.lastIndexOf(']');
        if (a >= 0 && b > a) s = s.slice(a, b + 1);
        else if (a >= 0) s = s.slice(a);            // 閉じ ] が無い → 末尾補完に任せる

        // 4) 寛容パーサで読み直す
        try {
          const localFixes = [];
          const data = lenientParseJson_(s, localFixes);
          if (!Array.isArray(data)) throw new Error('配列ではありません');
          // 文字列が閉じていない＝出力が途中で壊れている(例文の混入・途中終了)。中途半端なデッキを通さず、従来のエラーに落とす
          if (localFixes.indexOf('閉じ引用符を補いました') >= 0) throw new Error('文字列が閉じていません（出力が途中で壊れています）');
          // 途中で ```json から書き直された出力（実測 2026-09-05: Flash系で発生）。前半だけの中途半端なデッキを通さない。
          // 書き直しマーカーが JSON の途中にあり、かつ最後のページが closing でなければ失敗にする
          const last = data[data.length - 1];
          if (restartInside && !(last && last.type === 'closing')) throw new Error('出力が途中で書き直されています（デッキが途中で切れています）');
          // 配列や要素の閉じ括弧を補って読めた＝出力が途中で切れている（実測 2026-09-06: 生成が途中で止まり英語の挨拶が続く）。
          // 最後のページが closing でなければ、途中までのデッキを通さない。
          const autoClosed = localFixes.some(function (f) { return f.indexOf('閉じ ] を補いました') >= 0 || f.indexOf('閉じ } を補いました') >= 0; });
          if (autoClosed && !(last && last.type === 'closing')) throw new Error('出力が途中で切れています（最後のページまで届いていません）。Gemini に「構成データを出力して」と送り直してください');
          // カスタム指示に載せている「表→JSON の対応例」（新制度の説明会）がそのまま出力された場合は、正しいデッキではないので通さない
          // （実測 2026-09-06: Workspace の Flash が途中で例文へ書き直した。例文は完全な3ページなので、放置すると無関係なデッキが生成される）
          if (isInstructionExample_(data)) throw new Error('カスタム指示の例文がそのまま出力されています。Gemini に「構成データを出力して」と送り直してください');
          const outText = JSON.stringify(data, null, 2);
          JSON.parse(outText); // 念のため
          if (cand.note) fixes.push(cand.note);
          fixes.push(...localFixes);
          return { ok: true, data: data, text: outText, fixes: fixes };
        } catch (e) {
          // ★2026-09-07: 中身を見て止めた場合（途中切れ・例文）は、利用者に見せる案内文として扱う
          var guard = e && e.message && (e.message.indexOf('出力が途中で') >= 0 || e.message.indexOf('カスタム指示の例文') >= 0 || e.message.indexOf('文字列が閉じていません') >= 0);
          if (guard) return { ok: false, error: e.message, guard: true, fixes: fixes };
          if (cand === candidates[candidates.length - 1]) return { ok: false, error: e.message, fixes: fixes };
        }
      }
      return { ok: false, error: '修復できませんでした', fixes: fixes };
    }

    function isInstructionExample_(data) {
      const EXAMPLE_TITLES = ['新制度の説明会', '制度の3つの柱', '新しい勤務制度のご案内'];
      let hits = 0;
      for (const p of data) {
        const t = (p && typeof p.title === 'string') ? p.title : '';
        if (EXAMPLE_TITLES.indexOf(t.trim()) >= 0) hits++;
      }
      // 例文由来のタイトルが1つでも混ざっていれば例文（正しい依頼の題材にこの語が出ることは無い）
      return hits >= 1;
    }

    // ---- 寛容パーサ本体 ----
    function lenientParseJson_(src, fixes) {
      const toks = tokenizeLenient_(src, fixes);
      let i = 0;
      const peek = (k) => toks[i + (k || 0)];
      const next = () => toks[i++];
      const note = (msg) => { if (fixes.indexOf(msg) < 0) fixes.push(msg); };

      function parseValue() {
        const t = peek();
        if (!t) throw new Error('入力が途中で終わっています');
        if (t.type === 'punct') {
          if (t.value === '{') return parseObject();
          if (t.value === '[') return parseArray();
          throw new Error('予期しない記号 ' + t.value + ' (位置 ' + t.pos + ')');
        }
        next();
        if (t.type === 'string') return t.value;
        if (t.type === 'number') return t.value;
        if (t.type === 'literal') return t.value;
        if (t.type === 'ident') { note('引用符の無い値を文字列として扱いました'); return t.value; }
        throw new Error('不明なトークン');
      }

      function isKeyStart(k) { // 位置 k に「キー :」があるか
        const a = toks[i + (k || 0)], b = toks[i + (k || 0) + 1];
        return !!(a && (a.type === 'string' || a.type === 'ident') && b && b.type === 'punct' && b.value === ':');
      }

      function parseObject(opts) {
        // opts.synthetic: 配列内で { が無いまま "key": が始まった場合の擬似オブジェクト
        const synthetic = !!(opts && opts.synthetic);
        if (!synthetic) next(); // '{'
        const obj = {};
        for (;;) {
          let t = peek();
          if (!t) { note('閉じ } を補いました'); return obj; }
          if (t.type === 'punct' && t.value === '}') { next(); return obj; }
          if (t.type === 'punct' && t.value === ',') { next(); continue; }        // 余分なカンマ / 末尾カンマ
          if (t.type === 'punct' && t.value === ']') { note('閉じ } を補いました'); return obj; }   // } 抜けで配列が終わった
          if (t.type === 'punct' && t.value === '{') { note('閉じ } を補いました'); return obj; }   // } 抜けで次の要素が始まった
          if (!isKeyStart(0)) {
            // キーの位置に「キー :」ではないものが来た。文字列・数値・真偽値なら迷子の値として捨てる
            // (実測 2026-09-05: `"statsCompare: true",` のような壊れた1トークンが混ざる)
            if (t.type === 'string' || t.type === 'number' || t.type === 'literal' || t.type === 'ident') { next(); note('迷子の値を除去しました'); continue; }
            if (t.type === 'punct' && t.value === '[') { parseArray(); note('迷子の配列を除去しました'); continue; }
            if (t.type === 'punct' && t.value === ':') { next(); note('余分なコロンを除去しました'); continue; }
            throw new Error('キーが見つかりません (位置 ' + t.pos + ')');
          }
          const keyTok = next(); next(); // key, ':'
          if (keyTok.type === 'ident') note('キーに引用符を補いました');
          const key = String(keyTok.value);
          if (synthetic && Object.prototype.hasOwnProperty.call(obj, key)) {
            // 同じキーがもう一度出た → { } 抜けで次のオブジェクトが始まったとみなす
            i -= 2; note('配列内の { } を補いました'); return obj;
          }
          obj[key] = parseValue();
          t = peek();
          if (t && t.type === 'punct' && t.value === ',') { next(); continue; }
          if (t && t.type === 'punct' && (t.value === '}' || t.value === ']' || t.value === '{')) continue;
          if (t && isKeyStart(0)) { note('カンマを補いました'); continue; }
          if (!t) continue;
          throw new Error('オブジェクト内の予期しないトークン (位置 ' + t.pos + ')');
        }
      }

      function parseArray() {
        next(); // '['
        const arr = [];
        for (;;) {
          const t = peek();
          if (!t) { note('閉じ ] を補いました'); return arr; }
          if (t.type === 'punct' && t.value === ']') { next(); return arr; }
          if (t.type === 'punct' && t.value === ',') { next(); continue; }
          if (t.type === 'punct' && t.value === '}') { next(); note('余分な } を無視しました'); continue; }
          if (isKeyStart(0)) { note('配列内の { } を補いました'); arr.push(parseObject({ synthetic: true })); continue; }
          arr.push(parseValue());
          const u = peek();
          if (u && u.type === 'punct' && u.value === ',') { next(); continue; }
          if (u && u.type === 'punct' && u.value === ']') continue;
          if (u) { note('カンマを補いました'); continue; }
        }
      }

      const v = parseValue();
      // 末尾に余りがあっても(閉じ忘れ等)無視する
      return v;
    }

    function tokenizeLenient_(src, fixes) {
      const toks = [];
      const note = (msg) => { if (fixes.indexOf(msg) < 0) fixes.push(msg); };
      let i = 0; const n = src.length;
      while (i < n) {
        const c = src[i];
        if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === ' ' || c === '﻿') { i++; continue; }
        if (c === '/' && src[i + 1] === '/') { while (i < n && src[i] !== '\n') i++; note('コメントを除去しました'); continue; }
        if ('{}[]:,'.indexOf(c) >= 0) { toks.push({ type: 'punct', value: c, pos: i }); i++; continue; }
        if (c === '"' || c === "'" || c === '“' || c === '”') {
          const q = (c === "'") ? "'" : '"';
          if (c === "'" ) note('シングルクォートを二重引用符に直しました');
          if (c === '“' || c === '”') note('全角引用符を直しました');
          const start = i; i++;
          let s = '';
          for (;;) {
            if (i >= n) { note('閉じ引用符を補いました'); break; }
            const d = src[i];
            if (d === '\\') {
              const e = src[i + 1];
              if (e === undefined) { i++; break; }
              if (e === 'n') s += '\n'; else if (e === 't') s += '\t'; else if (e === 'r') s += '\r';
              else if (e === 'b') s += '\b'; else if (e === 'f') s += '\f';
              else if (e === 'u') { const h = src.substr(i + 2, 4); if (/^[0-9a-fA-F]{4}$/.test(h)) { s += String.fromCharCode(parseInt(h, 16)); i += 4; } else { s += 'u'; } }
              else s += e;   // \" \\ \/ など
              i += 2; continue;
            }
            if (d === q || (q === '"' && (d === '”'))) { i++; break; }
            if (d === '\n' || d === '\r') { note('文字列内の改行をエスケープしました'); }
            s += d; i++;
          }
          toks.push({ type: 'string', value: s, pos: start });
          continue;
        }
        if (c === '-' || (c >= '0' && c <= '9')) {
          const m = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(src.slice(i, i + 40));
          if (m) { toks.push({ type: 'number', value: Number(m[0]), pos: i }); i += m[0].length; continue; }
        }
        const m2 = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(src.slice(i, i + 64));
        if (m2) {
          const w = m2[0];
          if (w === 'true') toks.push({ type: 'literal', value: true, pos: i });
          else if (w === 'false') toks.push({ type: 'literal', value: false, pos: i });
          else if (w === 'null') toks.push({ type: 'literal', value: null, pos: i });
          else toks.push({ type: 'ident', value: w, pos: i });
          i += w.length; continue;
        }
        // その他の文字(全角記号など)は読み飛ばす
        note('不要な文字を除去しました');
        i++;
      }
      return toks;
    }


    function stripTrailingCommas(text) {
      let out = '';
      let inString = false;
      let escaped = false;
      for (let i = 0; i < text.length; i++) {
        const ch = text[i];
        if (inString) {
          out += ch;
          if (escaped) { escaped = false; }
          else if (ch === '\\') { escaped = true; }
          else if (ch === '"') { inString = false; }
          continue;
        }
        if (ch === '"') { inString = true; out += ch; continue; }
        if (ch === ',') {
          // このカンマの後ろが（空白をはさんで） } か ] なら、それは末尾カンマ
          let j = i + 1;
          while (j < text.length && /\s/.test(text[j])) j++;
          if (j < text.length && (text[j] === '}' || text[j] === ']')) {
            continue; // カンマを書き出さない＝削除
          }
        }
        out += ch;
      }
      return out;
    }

    function validateJSON() {
      const textarea = $('slideDataInput');
      if (!textarea) return;

      const jsonText = textarea.value.trim();
      
      const validationDiv = $('jsonValidation');
      if (validationDiv) validationDiv.style.display = 'none';

      if (!jsonText) {
        if (SlideEditor && !SlideEditor.isEditorActive()) {
          SlideEditor.updatePreviewButtonForEditor(false);
        }
        return;
      }
      
      let parsedData = null;
      let parseError = null;

      // --- ステップ1〜3: パースとクリーニング (既存のまま) ---
      try {
        parsedData = JSON.parse(jsonText);
      } catch (firstError) {
        parseError = firstError;
        console.log('JSONパース失敗。クリーニングを試行します...', firstError.message);
        // ★自動修復(2026-09-05): 末尾カンマ・カンマ抜け・キーの引用符抜け・配列内 { } 抜け・迷子の値・
        //   文字列内の生改行・途中書き直し・コードフェンス を寛容パーサで読み直して正しい JSON に組み直す。
        //   (Flash-Lite の生成品質による「惜しい JSON」を利用者に見せずに通すため)
        const __rep = repairSlideDataJson(jsonText);
        if (__rep.ok && !__rep.unchanged) {
            parsedData = __rep.data;
            parseError = null;
            textarea.value = __rep.text;
            const __what = (__rep.fixes && __rep.fixes.length) ? '（' + __rep.fixes.join('・') + '）' : '';
            console.log('[validateJSON] JSONを自動修復しました', __rep.fixes);
            showStatus('構成データのJSONを自動修復しました' + __what, 'success', 6000);
        } else if (__rep.guard) {
            // ★2026-09-07: 中身を見て止めた場合は、利用者向けの案内文をそのまま出す
            showJsonErrorModal(__rep.error);
            return;
        } else {
            parseError = firstError;
        }
      }

      // --- ステップ4: パースエラーの判定 ---
      if (parseError) {
        showJsonErrorModal('JSONパースエラー: ' + parseError.message);
        return;
      }

      // --- ステップ5: 内容の検証 (ここに追加) ---
      try {
        if (!Array.isArray(parsedData)) throw new Error('slideDataは配列である必要があります');

        // ▼▼▼ 【ここを追加】 未定義のスライドタイプチェック ▼▼▼
        const invalidTypes = [];
        parsedData.forEach((slide, index) => {
          if (slide && slide.type && !VALID_SLIDE_TYPES.includes(slide.type)) {
            invalidTypes.push(`- スライド${index + 1}: "${slide.type}"`);
          }
        });

        if (invalidTypes.length > 0) {
          // Geminiへのフィードバック用メッセージを作成
          const errorMessage = `定義に無いスライドタイプが選択されています。以下の修正を行ってください。

【エラー内容】
以下のスライドタイプはシステムに存在しません：
${invalidTypes.join('\n')}

【有効なスライドタイプ一覧】
${VALID_SLIDE_TYPES.join(', ')}

上記のエラーを修正し、正しい "type" を指定したJSONを出力してください。`;

          showJsonErrorModal(errorMessage);
          return; // 処理を中断
        }
        // ▲▲▲ 【追加ここまで】 ▲▲▲


        // ▼▼▼ sectionNo 自動連番付与 (既存ロジック) ▼▼▼
        let sectionCount = 0;
        let dataModified = false;

        parsedData.forEach(slide => {
          if (slide && slide.type === 'section') {
            sectionCount++;
            if (slide.sectionNo === undefined || slide.sectionNo === null || slide.sectionNo === "") {
              slide.sectionNo = sectionCount;
              dataModified = true;
            }
          }
        });
        if (dataModified) {
          textarea.value = JSON.stringify(parsedData, null, 2);
        }
        
        // 必須項目のチェック (既存ロジック)
        let warnings = [];
        parsedData.forEach((slide, index) => {
          if (!slide.type) warnings.push(`スライド${index + 1}: typeプロパティが必要です`);
          if (slide.type === 'title' && !slide.title) warnings.push(`スライド${index + 1}: titleスライドにはtitleが必要です`);
        });

        if (warnings.length > 0) {
           showJsonErrorModal('警告: 以下の項目を確認してください\n\n' + warnings.join('\n'));
           return;
        }
        
        // 検証成功時の処理
        const autoActivationAllowed = !suppressEditorAutoActivation;
        const isTextareaVisible = textarea.offsetParent !== null;
        
        if (autoActivationAllowed && isTextareaVisible) {
          if (SlideEditor.isEditorActive()) {
            SlideEditor.exitEditorMode({ skipValidation: true });
          }
          SlideEditor.enterEditorMode();
        }

      } catch (validationError) {
         showJsonErrorModal('JSON検証エラー: ' + validationError.message);
      }
    }






    function updateGradientPreview() {
      const startColor = $('gradientStartText').value, endColor = $('gradientEndText').value, preview = $('gradientPreview');
      if (preview) { preview.style.background = `linear-gradient(135deg, ${startColor}, ${endColor})`; }
    }

    function openFolder() {
      const folderUrl = $('driveFolderUrl').value.trim();
      if (!folderUrl) { showStatus('保存先フォルダURLを入力してください', 'error'); return; }
      if (!folderUrl.includes('drive.google.com') && !folderUrl.includes('docs.google.com')) {
        showStatus('正しいGoogleドライブフォルダURLを入力してください', 'error'); return;
      }
      window.open(folderUrl, '_blank');
    }
    
    window.addEventListener('load', function() {
        adjustTextAreaHeight();
    });

    document.addEventListener('DOMContentLoaded', function() {

    // ▼▼▼ [変更] アクティベーションフロー制御モジュール (select対応 / 5ステップ版) ▼▼▼
    const ActivationFlow = (function() {
      let modals = [];
      let currentStep = -1;
      let userEmail = null;
      let onCompleteCallback = null;

      function init(email, onComplete) {
        modals = [
          $('activationModal1'),
          $('activationModal2'),
          $('activationModal3'),
          $('activationModal4'),
          $('termsModal') // ◀ [変更] 5番目のモーダルは 'termsModal' を使用
        ];
        userEmail = email;
        onCompleteCallback = onComplete;
      }

      function showModal(stepIndex) {
        if (stepIndex < 0 || stepIndex >= modals.length) return;
        
        // 以前のモーダルを隠す
        if (currentStep >= 0 && modals[currentStep]) {
          modals[currentStep].style.display = 'none';
        }
        
        currentStep = stepIndex;
        const modal = modals[currentStep];
        
        // ▼ [変更] ステップ5 (termsModal) の場合はUIを切り替える
        if (currentStep === 4) { 
          $('termsModalHeader').textContent = 'ステップ 5 / 5 - 利用条件の確認';
          $('termsModalStandardButtons').style.display = 'none';
          $('termsModalActivationButtons').style.display = 'block';
        }

        // 次のモーダルを表示
        modal.style.display = 'flex';
        
        // ボタンにイベントリスナーを（一度だけ）設定
        const confirmBtn = $(`confirmModal${currentStep + 1}`);
        const purchaseSelect = $(`purchaseModal${currentStep + 1}`); // ◀ 'select' に変更
        
        const confirmHandler = () => {
          // リスナーを削除
          confirmBtn.removeEventListener('click', confirmHandler);
          purchaseSelect.removeEventListener('change', purchaseHandler); // ◀ 'select' に変更
          // 次のステップへ
          handleNext();
        };

        const purchaseHandler = (e) => { // ◀ 'select' 用のハンドラ
          if (e.target.value === 'purchased') {
            // リスナーを削除
            confirmBtn.removeEventListener('click', confirmHandler);
            purchaseSelect.removeEventListener('change', purchaseHandler);
            // 次のステップへ
            handleNext();
          }
        };

        confirmBtn.addEventListener('click', confirmHandler);
        purchaseSelect.addEventListener('change', purchaseHandler); // ◀ 'change'イベントに変更
      }

      function handleNext() {
        const nextStep = currentStep + 1;
        if (nextStep < modals.length) {
          // 次のモーダルがある場合
          showModal(nextStep);
        } else {
          // フローが完了した場合
          if (modals[currentStep]) {
            // ▼ [変更] ステップ5 (termsModal) の場合はUIを元に戻す
            if (currentStep === 4) {
              $('termsModalHeader').textContent = '利用条件 (Terms of Service)';
              $('termsModalStandardButtons').style.display = 'block';
              $('termsModalActivationButtons').style.display = 'none';
            }
            modals[currentStep].style.display = 'none';
          }
          console.log('アクティベーションフロー完了');
          
          // ★★★ [ここから変更] ★★★
          
          // サーバーへのアクティベート通知は先に行う
          if (userEmail) {
            google.script.run
              .withSuccessHandler(response => {
                console.log('サーバーでのアクティベート完了:', response.message);
              })
              .withFailureHandler(error => {
                console.error('サーバーでのアクティベート失敗:', error.message);
                // 失敗してもアプリの利用は許可する (善意ベースのため)
              })
              .activateUser(userEmail);
          }
          
          // メンバーシップモーダルを取得
          const membershipModal = $('membershipModal');
          const closeMembershipBtn = $('closeMembershipModalBtn');
          
          if (membershipModal && closeMembershipBtn) {
              // モーダルが存在すれば表示
              console.log('メンバーシップ紹介モーダルを表示します。');
              membershipModal.style.display = 'flex';
              
              // 閉じるボタンに「アプリ起動」の処理を1回だけ設定
              closeMembershipBtn.onclick = () => {
                  console.log('メンバーシップモーダルを閉じました。アプリを起動します。');
                  membershipModal.style.display = 'none';
                  showStatus('ありがとうございます。ご利用を開始できます。', 'success');
                  
                  // アプリ本体の初期化を実行
                  if (typeof onCompleteCallback === 'function') {
                      onCompleteCallback(); // ここで initializeAppLogic() が実行されます
                  }
              };
          } else {
              // モーダルが見つからない場合は、フォールバックして即時アプリ起動
              console.warn('メンバーシップモーダルが見つからないため、アプリを即時起動します。');
              showStatus('ありがとうございます。ご利用を開始できます。', 'success');
              if (typeof onCompleteCallback === 'function') {
                  onCompleteCallback();
              }
          }
          // ★★★ [変更ここまで] ★★★
        }
      }

      function start() {
        if (!userEmail) {
          console.log('ユーザーEmail不明のため、アクティベーションをスキップしてアプリを起動します。');
          if (typeof onCompleteCallback === 'function') {
            onCompleteCallback(); // Emailが取れない場合もアプリは起動
          }
          return;
        }
        console.log('アクティベーションフローを開始します...');
        showModal(0); // 最初のモーダルを表示
      }

      return {
        init: init,
        start: start
      };
    })();
    // ▲▲▲ [変更] アクティベーションモジュールここまで ▲▲▲


    /**
     * アプリ本体の初期化処理
     * (元々 DOMContentLoaded にあった処理をこの関数にラップ)
     */
    function initializeAppLogic() {
      // ▼▼▼ [移植] 元の DOMContentLoaded の処理 ▼▼▼
      const geminiButton = $('geminiGemButton');



      if (!loadedSettings.geminiGemUrl) {
        const modal = $('gemUrlModal');
        const saveBtn = $('saveGemUrlBtn');
        const input = $('gemUrlInput');
        const errorEl = $('gemUrlError');
        
        // [修正 1] toggleBtn と promptLoaded の定義を削除
        // const toggleBtn = $('toggleGemHelpBtn'); 
        const helpArea = $('gemHelpArea');
        const promptTextarea = $('gemPromptTextarea');
        const copyBtn = $('copyGemPromptBtn');
        // let promptLoaded = false;
        
        modal.style.display = 'flex';
        function validateGemUrlInput() {
          const url = input.value.trim();
          const baseUrl = 'https://gemini.google.com/gem/';
          if (!url) {
            errorEl.textContent = 'URLを入力してください。';
            errorEl.style.color = 'var(--danger-color)';
            errorEl.style.display = 'none';
            saveBtn.disabled = true;
            return 'error';
          } else if (!url.startsWith(baseUrl)) {
            errorEl.textContent = 'URLの形式が不正です (https://gemini.google.com/gem/... を推奨)。このまま続行しますか？';
            errorEl.style.color = 'var(--danger-color)';
            errorEl.style.display = 'block';
            saveBtn.disabled = false;
            return 'warning';
          } else if (url.length === baseUrl.length) {
            errorEl.textContent = '特定のGemが指定されていません（GeminiホームのURLです）。このまま続行しますか？';
            errorEl.style.color = 'var(--danger-color)';
            errorEl.style.display = 'block';
            saveBtn.disabled = false;
            return 'warning';
          } else {
            errorEl.style.display = 'none';
            saveBtn.disabled = false;
            return 'success';
          }
        }
        input.addEventListener('input', validateGemUrlInput);
        validateGemUrlInput();

        // [修正 2] プロンプト読み込み処理をここに移動し、即時実行する
        // (toggleBtn.addEventListener を削除)
        try {
           const promptDataElement = $('gem-prompt-data');
          if (promptDataElement) {
            const promptText = promptDataElement.textContent.trim();
            promptTextarea.value = promptText || 'プロンプトが空です。';
          } else {
            promptTextarea.value = 'エラー: HTML内に id="gem-prompt-data" が見つかりません。';
          }
        } catch (e) {
          promptTextarea.value = 'エラー: ' + e.message;
        }

        // [修正] コピー処理は共通関数 bindGemPromptCopyButton/copyGemPromptText に集約
        // （navigator.clipboard 未定義時の同期例外でも確実にフォールバックするようにした）
        bindGemPromptCopyButton();
        saveBtn.addEventListener('click', function() {
          const url = input.value.trim();
          errorEl.style.display = 'none';
          saveBtn.disabled = true;
          saveBtn.textContent = '保存中...';
          
          google.script.run
            .withSuccessHandler(function(response) {
              if (response.status === 'success' || response.status === 'warning') {
                const url = input.value.trim();
                if (geminiButton) {
                  geminiButton.href = url;
                }
                loadedSettings.geminiGemUrl = url;
                $('gemUrlModal').style.display = 'none';
                if (response.status === 'warning') {
                   showStatus('Gem URLが保存されました（警告あり）。', 'info', 4000);
                } else {
                   showStatus('Gem URLを保存しました。', 'success');
                }
                if(typeof resumeTimer === 'function') resumeTimer();
              } else {
                errorEl.textContent = response.message;
                errorEl.style.color = 'var(--danger-color)';
                errorEl.style.display = 'block';
                saveBtn.disabled = false;
                saveBtn.textContent = '保存して開始';
                if(typeof resumeTimer === 'function') resumeTimer();
              }
            })
            .withFailureHandler(function(error) {
              errorEl.textContent = 'サーバーエラー: ' + error.message;
              errorEl.style.display = 'block';
              saveBtn.disabled = false;
              saveBtn.textContent = '保存して開始';
              if(typeof resumeTimer === 'function') resumeTimer();
            })
            .saveGemUrl(url);
        });
      } else {
        if (geminiButton) {
            geminiButton.href = loadedSettings.geminiGemUrl;
        }
      }
    
      SlideEditor.init();

      // 新5: 管理者メニューの配線＋管理者情報/共有プリセットの取得
      try { wireAdminMenu(); refreshAdminInfo(); } catch (e) { console.warn('管理者メニュー初期化に失敗:', e && e.message); }

      function showPreviewInfoModal(isFirstTime = false) {
        const modal = $('previewInfoModal');
        const firstTimeMsg = $('previewInfoModal_FirstTimeMessage');
        const closeBtn = $('closePreviewInfoModalBtn');
        if (!modal || !firstTimeMsg || !closeBtn) return;
        if (isFirstTime) {
          firstTimeMsg.innerHTML = '<p style="font-weight: 600;">設定が保存されました。</p><p>ご利用を開始できます。プレビュー版の注意事項をご確認の上、ご利用ください。</p>';
          firstTimeMsg.style.display = 'block';
          closeBtn.textContent = '閉じる';
          closeBtn.disabled = false; 
          closeBtn.style.opacity = '1';
          closeBtn.style.cursor = 'pointer';
        } else {
          firstTimeMsg.style.display = 'none';
          closeBtn.textContent = '閉じる';
          closeBtn.disabled = false; 
          closeBtn.style.opacity = '1';
          closeBtn.style.cursor = 'pointer';
        }
        modal.style.display = 'flex';
      }
      function hidePreviewInfoModal(isFirstTime = false) {
        const modal = $('previewInfoModal');
        if (modal) {
          modal.style.display = 'none';
        }
        if (isFirstTime) {
          localStorage.setItem('hasShownPreviewModal_v1', 'true');
          const previewHeader = $('previewHeader');
          if (previewHeader) {
             previewHeader.style.display = 'block';
          }
        }
      }

      const previewHeader = $('previewHeader');
      const closePreviewHeaderBtn = $('closePreviewHeaderBtn');
      const previewInfoModal = $('previewInfoModal');
      const closePreviewInfoModalBtn = $('closePreviewInfoModalBtn');
      if (previewHeader && closePreviewHeaderBtn && previewInfoModal && closePreviewInfoModalBtn) {
      if (ENABLE_CREDIT_IMAGE === true) {
        if (hasShownFirstTimePreviewModal) {
          previewHeader.style.display = 'block';
        } else {
          if (!loadedSettings.geminiGemUrl) {
             previewHeader.style.display = 'none';
          } else {
             previewHeader.style.display = 'block';
          }
        }
      } else { // ◀◀◀ 修正: このELSEブロックを追加
            previewHeader.style.display = 'none'; // クレジット無効なら常に非表示
      }
        previewHeader.addEventListener('click', (e) => {
          if (e.target === closePreviewHeaderBtn) return;
          try {
            // ▼ [変更] termsModal を表示する (UIは通常モードのはず)
            const modal = $('termsModal');
            const iframe = $('termsFrame');
            const targetUrl = 'https://docs.google.com/document/d/e/2PACX-1vSjM9eBeZvqaRzGIlyjPiB_CuhSBSjsJY7eahzMxoch-WQGLwrOcUAaDv1cxOll720J8bib1XoGlG8J/pub?embedded=true';
            
            if (modal && iframe) {
              if (iframe.src !== targetUrl) {
                iframe.src = targetUrl;
              }
              modal.style.display = 'flex';
            } else {
              console.error('利用規約モーダルの要素が見つかりません。');
            }
          } catch (err) {
            console.error('利用規約モーダルの表示に失敗:', err);
            // (フォールバックの previewInfoModal は削除されたので何もしない)
          }
        });
        closePreviewHeaderBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          previewHeader.style.display = 'none';
        });
        closePreviewInfoModalBtn.addEventListener('click', () => {
          const isFirstTime = ($('previewInfoModal_FirstTimeMessage').style.display === 'block');
          hidePreviewInfoModal(isFirstTime);
        });
        previewInfoModal.addEventListener('click', (e) => {
          if (e.target === previewInfoModal) {
            const isFirstTime = ($('previewInfoModal_FirstTimeMessage').style.display === 'block');
            hidePreviewInfoModal(isFirstTime);
          }
        });
      }


      
      // 1. 先に <select> の中身を作成します
      updatePresetSelect(); 
      
      // 2. localStorage から最後に選択したプリセット名を取得します
      const lastSelectedPreset = localStorage.getItem('slideFactory_lastSelectedPreset');
      
      if (lastSelectedPreset) {
        // 最後に選択したプリセット名が保存されていた場合
        const select = $('presetSelect');
        
        // 3. プリセットリスト（<select>）にその名前が存在するか確認します
        if (Array.from(select.options).some(opt => opt.value === lastSelectedPreset)) {
          // 存在する場合：そのプリセットを適用します
          console.log('最後に選択したプリセットを適用:', lastSelectedPreset);
          select.value = lastSelectedPreset; // ドロップダウンを選択状態にする
          updatePresetMenuState();
          loadSelectedPreset(); // プリセットの値をフォームに適用する
        } else {
          // 存在しない場合 (削除されたなど)：デフォルト設定を適用します
          console.log('最後に選択したプリセットが見つからないため、デフォルトを適用します。');
          select.value = '_default'; // ドロップダウンを「デフォルト設定」にする
          updatePresetMenuState();
          loadSettingsToForm(); // デフォルト値をフォームに適用する
        }
      } else {
        // 保存された値がない場合 (初回起動など)：デフォルト設定を適用します
        console.log('最後に選択したプリセットがないため、デフォルトを適用します。');
        // loadSettingsToForm() はデフォルトで _default が選択された状態で
        // フォームを初期化するため、select.value の設定は不要です。
        loadSettingsToForm();
      }




      $('generateBtn').addEventListener('click', generate);
      $('primaryColor').addEventListener('input', function() { updatePrimaryColor(this.value); });
      $('primaryColorText').addEventListener('input', function() { updatePrimaryColor(this.value); });
      $('gradientStart').addEventListener('input', function() { $('gradientStartText').value = this.value; updateGradientPreview(); });
      $('gradientStartText').addEventListener('input', function() { $('gradientStart').value = this.value; updateGradientPreview(); });
      $('gradientEnd').addEventListener('input', function() { $('gradientEndText').value = this.value; updateGradientPreview(); });
      $('gradientEndText').addEventListener('input', function() { $('gradientEnd').value = this.value; updateGradientPreview(); });
      $('enableGradient').addEventListener('change', toggleGradientOptions);
      if ($('enableGraphAnimation')) { $('enableGraphAnimation').addEventListener('change', toggleGraphAnimFormat); toggleGraphAnimFormat(); }


      const slideDataInput = $('slideDataInput');
      slideDataInput.addEventListener('input', validateJSON);
      
      // ★ 追加: ダブルクリックでクリップボードから貼り付け
      slideDataInput.addEventListener('dblclick', async function() {
        try {
          // クリップボードのテキストを読み取る
          const text = await navigator.clipboard.readText();
          if (text) {
            this.value = text;
            
            // バリデーションと高さ調整を実行
            validateJSON();
            if (typeof SlideEditor !== 'undefined' && SlideEditor.autoResizeTextarea) {
               SlideEditor.autoResizeTextarea(this);
            }
            
            showStatus('クリップボードの内容を貼り付けました', 'success');
          }
        } catch (e) {
          console.warn('Clipboard paste failed:', e);
          // 読み取り権限がない場合などは、通常の単語選択動作（ブラウザ標準）を邪魔しないよう何もしないか、アラートを出す
          // alert('クリップボードの読み取りに失敗しました。Ctrl+V を利用してください。');
        }
      });


      $('largeFontColor').addEventListener('input', function() { updateLargeFontColor(this.value); });
      $('largeFontColorText').addEventListener('input', function() { updateLargeFontColor(this.value); });
      $('smallFontColor').addEventListener('input', function() { updateSmallFontColor(this.value); });
      $('smallFontColorText').addEventListener('input', function() { updateSmallFontColor(this.value); });
      $('backgroundColor').addEventListener('input', function() { updateBackgroundColor(this.value); });
      $('backgroundColorText').addEventListener('input', function() { updateBackgroundColor(this.value); });
      $('presetSelect').addEventListener('change', function() {
        // ★カラーテーマ選択時: 配色のみ適用し、選択状態は直前のプリセットへ戻す（テーマはプリセットではなく"適用アクション"）
        if (this.value && this.value.indexOf(THEME_PRESET_OPTION_PREFIX) === 0) {
          const themeKey = this.value.slice(THEME_PRESET_OPTION_PREFIX.length);
          if (typeof applyThemePreset === 'function') applyThemePreset(themeKey);
          let back = '';
          try { back = localStorage.getItem('slideFactory_lastSelectedPreset') || ''; } catch (e) {}
          if (!back || !Array.from(this.options).some(o => o.value === back)) back = '_default';
          this.value = back;
          updatePresetMenuState();
          return;
        }
        updatePresetMenuState();
        if (this.value) {
          loadSelectedPreset();
          try {
            localStorage.setItem('slideFactory_lastSelectedPreset', this.value);
            console.log('最後に選択したプリセットを保存しました:', this.value);
          } catch (e) {
            console.warn('ローカルストレージへのプリセット名保存に失敗:', e.message);
          }
        }
      });





      // --- ヘルパー: 要約スライドをデータに追加/更新し、Googleスライドにも反映する関数 ---
        async function addOrUpdateSummarySlide(base64Image) {
            // SlideEditorがアクティブでない場合は強制起動
            if (!SlideEditor.isEditorActive()) {
                SlideEditor.enterEditorMode();
            }

            const slideData = SlideEditor.getSlideData();
            const currentIndex = SlideEditor.getCurrentIndex();
            const currentSlide = slideData[currentIndex];

            let targetIndex = currentIndex;
            let isUpdateMode = false; // 更新か新規追加かのフラグ
            let newSlideData = null;

            // --- 1. ローカルデータの更新 ---
            if (currentSlide && currentSlide.isSummary) {
                // A. 既存の要約スライドを更新
                currentSlide.image = base64Image;
                isUpdateMode = true;
                targetIndex = currentIndex;
                newSlideData = currentSlide;
                //showStatus('ローカルデータを更新しました。Googleスライドへ反映中...', 'info');
            } else {
                // B. 新規追加
                const newSlide = {
                    "type": "fullImage",
                    "notes": "ここまで説明した内容をまとめて振り返ります。",
                    "image": base64Image,
                    "isSummary": true
                };

                const lastIdx = slideData.length - 1;
                const lastSlide = slideData[lastIdx];

                if (lastSlide && lastSlide.type === 'closing') {
                    // Closingの前に挿入
                    slideData.splice(lastIdx, 0, newSlide);
                    targetIndex = lastIdx;
                } else {
                    // 末尾に追加
                    slideData.push(newSlide);
                    targetIndex = slideData.length - 1;
                }
                isUpdateMode = false;
                newSlideData = newSlide;
                //showStatus('ローカルに追加しました。Googleスライドへ追加中...', 'info');
            }

            // エディタをリフレッシュ (これにより currentIndex は 0 に戻る)
            SlideEditor.exitEditorMode({ skipValidation: true });
            SlideEditor.enterEditorMode();

            // ページ送りロジック (DOM更新に対応するためループ内でボタン再取得)
            if (targetIndex > 0) {
                for(let i=0; i < targetIndex; i++) {
                    const nextBtn = document.getElementById('editorNextBtn');
                    if(nextBtn && !nextBtn.disabled) {
                        nextBtn.click();
                    }
                }
            }

            // --- 2. Googleスライドへの反映 (プレゼンテーションIDがある場合) ---
            if (lastGeneratedSlideUrl) {
                const match = lastGeneratedSlideUrl.match(/\/d\/([a-zA-Z0-9_-]+)|\?id=([a-zA-Z0-9_-]+)/);
                const presentationId = match ? (match[1] || match[2]) : null;

                if (presentationId) {
                    const settings = getCurrentSettings();
                    const slideJsonString = JSON.stringify(newSlideData);

                    // ▼▼▼ [追加] ボタンのアニメーション＆タイマー開始 ▼▼▼
                    const updateBtn = $('updatePageBtn');
                    if (updateBtn) {
                        updateBtn.disabled = true; setSlideJobRunning(true, 'updatePageBtn'); /* ★[要望4] 実行中は「スライドを更新/生成」ボタンをロック */   // CSSアニメーション開始
                        startButtonTimer(updateBtn); // タイマーカウントアップ開始
                    }
                    // ▲▲▲ [追加] ここまで ▲▲▲

                    try {
                        if (isUpdateMode) {
                            // ★ 更新の場合
                            await new Promise((resolve, reject) => {
                                google.script.run
                                    .withSuccessHandler(resolve)
                                    .withFailureHandler(reject)
                                    .updateSingleSlide(presentationId, targetIndex, slideJsonString, settings, 'update');
                            });
                            //showStatus('Googleスライドの画像更新が完了しました！', 'success');
                        } else {
                            // ★ 新規追加の場合
                            await new Promise((resolve, reject) => {
                                google.script.run
                                    .withSuccessHandler(resolve)
                                    .withFailureHandler(reject)
                                    .insertSlideAt(presentationId, targetIndex, slideJsonString, settings);
                            });
                            //showStatus('Googleスライドへのページ追加が完了しました！', 'success');
                        }
                    } catch (e) {
                        console.error("Googleスライド同期エラー:", e);
                        showStatus(`Googleスライドへの反映に失敗しました: ${e.message}`, 'error');
                    } finally {
                        // ▼▼▼ [追加] ボタンを元に戻す ▼▼▼
                        if (updateBtn) {
                            const currentIdx = SlideEditor.getCurrentIndex();
                            const finalText = `${currentIdx + 1}ページ目を更新`;
                            stopButtonTimer(updateBtn, finalText);
                            updateBtn.disabled = false; setSlideJobRunning(false); /* ★[要望4] 排他解除(両ボタン復帰) */
                        }
                        // ▲▲▲ [追加] ここまで ▲▲▲
                    }
                }
            } else {
                //showStatus('Googleスライドがまだ生成されていないため、ローカルのみ更新しました。', 'success');
            }
        }




        // --- ヘルパー: 画像読み込み処理 (バックグラウンド実行版) ---
        function handleSummaryImageFile(file) {
            if (!file || !file.type.startsWith('image/')) {
                showStatus('画像ファイルを選択してください。', 'error');
                return;
            }

            // ▼▼▼ [修正] 待ち時間をなくすため、先にモーダルを閉じて通知を出す ▼▼▼
            const modal = $('summaryPromptModal');
            if (modal) modal.style.display = 'none';
            
            // 入力欄もリセットしておく
            const fileInput = $('summaryImageUpload');
            if (fileInput) fileInput.value = '';

            // ユーザーには処理中であることを伝える（操作はブロックしない）
            showStatus('画像を変換して取り込んでいます...', 'info', 3000);
            // ▲▲▲ [修正] ここまで ▲▲▲

            const reader = new FileReader();
            reader.onload = async (e) => {
                const base64 = e.target.result;
                // 非同期でデータ更新を実行
                await addOrUpdateSummarySlide(base64);
            };
            reader.onerror = () => {
                showStatus('画像の読み込みに失敗しました。', 'error');
            };
            reader.readAsDataURL(file);
        }





        // --- 1. 「要約スライドを追加」ボタンクリック (Event Delegation) ---
        // ボタンは動的に生成されるため、bodyで受けてIDで判定します
        document.body.addEventListener('click', function(event) {
            const btn = event.target.closest('#editorAddSummaryBtn');
            if (!btn) return;
            
            event.preventDefault();
            
            const summaryModal = $('summaryPromptModal');
            const summaryTextarea = $('summaryPromptTextarea');
            
            // 現在の設定値を取得（プロンプト条件用）
            const primaryColor = $('primaryColorText').value || '#4285F4';
            const footerText = $('footerText').value || '';
            const headerLogoUrl = $('headerLogoUrl').value || '';
            const backgroundColor = $('backgroundColorText').value || '#FFFFFF';
            
            // 編集モードの最新データをJSON化
            const currentData = SlideEditor.getSlideData();

            // ▼▼▼ [追加] プロンプト用JSONから画像データ(Base64)を除外する処理 ▼▼▼
            // 元データを変更しないようディープコピーを作成
            const dataForPrompt = JSON.parse(JSON.stringify(currentData));
            
            dataForPrompt.forEach(slide => {
                // 1. 文字列としてのBase64画像を省略
                if (typeof slide.image === 'string' && slide.image.startsWith('data:image/')) {
                    slide.image = "(image data omitted for prompt)";
                }
                // 2. オブジェクト内のBase64画像データも省略 (chart, animation等)
                else if (typeof slide.image === 'object' && slide.image !== null && slide.image.data && typeof slide.image.data === 'string' && slide.image.data.startsWith('data:image/')) {
                    slide.image.data = "(image data omitted for prompt)";
                }
                
                // 3. ゴースト画像(Base64)も不要なので削除
                if (slide.ghostImageBase64) {
                    delete slide.ghostImageBase64;
                }
            });

            // 加工したデータをJSON化
            const currentJson = JSON.stringify(dataForPrompt, null, 2);
            // ▲▲▲ [追加] ここまで ▲▲▲

            if (!currentJson) {
                showStatus('データがありません', 'error');
                return;
            }

            // ▼▼▼ 条件付きプロンプトの作成 (背景色指定・自動連番) ▼▼▼
    let conditions = [];

    // 1. プライマリカラー
    conditions.push(`${conditions.length + 1}. プライマリカラー: ${primaryColor} (画像全体でこの色に合う配色・デザインにすること。色番号を絶対に画像にテキストとして表示しないように。)`);

    // ★修正2: プライマリカラーの次に「背景色」の条件を追加
    conditions.push(`${conditions.length + 1}. 背景色: ${backgroundColor}(色番号を絶対に画像にテキストとして表示しないように。)`);
    
    const conditionText = conditions.join('\n');
    // ▲▲▲ [修正] ここまで ▲▲▲




            const promptText = 
`あなたはプロのプレゼンテーションデザイナーです。
以下のJSONデータは、現在作成中のスライド構成です。
このプレゼンテーションの内容全体を総括する「まとめ（Summary）」または「ハイライト」となるインフォグラフィック画像1枚をPNG画像（16:9）として4Kで作成してください。JSON形式他、コードやテキスト形式での出力は絶対にしないでください。

【デザイン条件】
${conditionText}

【現在のスライド構成データ】
${currentJson}`;

            if (summaryTextarea) summaryTextarea.value = promptText;
            if (summaryModal) summaryModal.style.display = 'flex';
        });


        // --- 2. モーダル内の各イベント設定 ---
        const summaryModal = $('summaryPromptModal');
        const summaryDropZone = $('summaryDropZone');
        const summaryImageUpload = $('summaryImageUpload');
        const summaryFileSelectLink = $('summaryFileSelectLink');





        // const copySummaryBtn = $('copySummaryPromptBtn'); // ← 削除
        const copyBtn = $('copySummaryOnlyBtn');   // ← 新規IDに変更
        const closeSummaryBtn = $('closeSummaryPromptBtn');
        const summaryTextarea = $('summaryPromptTextarea');
        
        if (summaryModal) {
            // ① プロンプトをコピーボタン
            if (copyBtn) {
                copyBtn.onclick = function() { 
                    const text = summaryTextarea.value;
                    if (!text) return;

                    summaryTextarea.select();
                    summaryTextarea.setSelectionRange(0, 99999); // モバイル対応

                    // 互換性の高い execCommand を優先
                    let copied = false;
                    try {
                        copied = document.execCommand('copy');
                    } catch (e) {
                        console.warn('execCommand failed:', e);
                    }

                    if (copied) {
                        showStatus('プロンプトをコピーしました', 'success');
                    } else {
                        // Clipboard API フォールバック
                        if (navigator.clipboard && navigator.clipboard.writeText) {
                            navigator.clipboard.writeText(text)
                                .then(() => showStatus('プロンプトをコピーしました', 'success'))
                                .catch(() => alert('コピーに失敗しました。手動でコピーしてください。'));
                        } else {
                            alert('コピーに失敗しました。手動でコピーしてください。');
                        }
                    }
                };
            }

            // ★ ダブルクリックで貼り付け機能の追加
            if (summaryDropZone) {
                summaryDropZone.addEventListener('dblclick', async (e) => {
                    // ファイル選択リンクをクリックした場合は除外
                    if (e.target.id === 'summaryFileSelectLink') return;

                    try {
                        // クリップボードの内容を取得
                        // ※ iframe内では権限が必要な場合があります
                        const clipboardItems = await navigator.clipboard.read();
                        let imageFound = false;

                        for (const item of clipboardItems) {
                            // 画像タイプを探す
                            const imageTypes = item.types.filter(type => type.startsWith('image/'));
                            if (imageTypes.length > 0) {
                                const blob = await item.getType(imageTypes[0]);
                                const file = new File([blob], "pasted_image.png", { type: imageTypes[0] });
                                
                                handleSummaryImageFile(file); // 既存の画像処理関数を呼ぶ
                                showStatus('クリップボードの画像を読み込みました', 'success');
                                imageFound = true;
                                break;
                            }
                        }

                        if (!imageFound) {
                            showStatus('クリップボードに画像が見つかりませんでした', 'info');
                        }
                    } catch (err) {
                        console.error('Clipboard read failed:', err);
                        // 権限エラー等の場合
                        alert('クリップボードの読み取りに失敗しました。\nCtrl+V (貼り付け) を試してください。');
                    }
                });
            }

            // 閉じるボタン






            if (closeSummaryBtn) {
                closeSummaryBtn.onclick = function() {
                    summaryModal.style.display = 'none';
                };
            }
            // 背景クリックで閉じる
            summaryModal.onclick = function(e) {
                if (e.target === summaryModal) summaryModal.style.display = 'none';
            };

            // --- アップロード関連イベント ---
            
            // ファイル選択リンク
            if (summaryFileSelectLink && summaryImageUpload) {
                summaryFileSelectLink.onclick = (e) => {
                    e.preventDefault();
                    summaryImageUpload.click();
                };
                
                summaryImageUpload.onchange = () => {
                    if (summaryImageUpload.files.length > 0) {
                        handleSummaryImageFile(summaryImageUpload.files[0]);
                        summaryImageUpload.value = ''; // リセット
                    }
                };
            }

            // ドラッグ＆ドロップ
            if (summaryDropZone) {
                summaryDropZone.ondragover = (e) => {
                    e.preventDefault();
                    summaryDropZone.classList.add('drag-over');
                };
                summaryDropZone.ondragleave = () => {
                    summaryDropZone.classList.remove('drag-over');
                };
                summaryDropZone.ondrop = (e) => {
                    e.preventDefault();
                    summaryDropZone.classList.remove('drag-over');
                    if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
                        handleSummaryImageFile(e.dataTransfer.files[0]);
                    }
                };
            }
            
            // ペースト対応 (モーダルが開いている時のみ)
            document.addEventListener('paste', (e) => {
                if (summaryModal.style.display === 'none') return;
                const items = (e.clipboardData || window.clipboardData).items;
                for (const item of items) {
                    if (item.kind === 'file' && item.type.startsWith('image/')) {
                        e.preventDefault();
                        handleSummaryImageFile(item.getAsFile());
                        break;
                    }
                }
            });
        }
        // ▲▲▲ [修正・拡張] ここまで ▲▲▲






      $('savePreset').addEventListener('click', saveCurrentPreset);
      $('presetMenuButton').addEventListener('click', togglePresetActionMenu);
      $('exportPreset').addEventListener('click', function() {
        closePresetActionMenu();
        exportSelectedPreset();
      });
      $('exportAllPresets').addEventListener('click', function() {
        closePresetActionMenu();
        exportAllUserPresets();
      });
      $('importPresets').addEventListener('click', function() {
        closePresetActionMenu();
        openPresetImportDialog();
      });
      $('deletePreset').addEventListener('click', function() {
        closePresetActionMenu();
        deleteSelectedPreset();
      });
      $('presetImportFile').addEventListener('change', handlePresetImportFileChange);
      $('openFolderBtn').addEventListener('click', openFolder);
      $('overwritePresetBtn').addEventListener('click', performOverwriteSave);
      $('saveAsNewPresetBtn').addEventListener('click', showNewPresetNameInput);
      $('confirmSaveBtn').addEventListener('click', performSaveAsNew);
      $('cancelNewNameBtn').addEventListener('click', cancelNewPresetName);
      $('closePresetModal').addEventListener('click', closePresetModal);
      $('presetSaveModal').addEventListener('click', function(e) {
        if (e.target === this) closePresetModal();
      });
      $('presetNameField').addEventListener('keypress', function(e) {
        if (e.key === 'Enter') performSaveAsNew();
      });
      $('presetConflictNewName').addEventListener('keypress', function(e) {
        if (e.key === 'Enter') $('presetConflictRenameBtn').click();
      });
      document.addEventListener('click', function(e) {
        const menuWrapper = document.querySelector('.preset-menu-wrapper');
        if (menuWrapper && !menuWrapper.contains(e.target)) closePresetActionMenu();
      });
      document.addEventListener('keydown', function(e) {
        if (e.key === 'Escape') closePresetActionMenu();
      });
      validateJSON(); 
      updateGradientPreview();
      document.querySelectorAll('.collapsible-header').forEach(header => {
        header.addEventListener('click', function() {
          this.classList.toggle('active');
          this.nextElementSibling.classList.toggle('active');
        });
      });
      function convertDriveUrl(url, extractFileIdOnly = false) {
        if (!url || !url.includes('drive.google.com')) return url;
        const patterns = [ /\/d\/([a-zA-Z0-9_-]+)/, /id=([a-zA-Z0-9_-]+)/, /\/file\/d\/([a-zA-Z0-9_-]+)/ ];
        for (let i = 0; i < patterns.length; i++) {
          const match = url.match(patterns[i]);
          if (match && match[1]) {
            const fileId = match[1];
            return extractFileIdOnly ? fileId : 'https://drive.google.com/uc?export=view&id=' + fileId;
          }
        }
        return url;
      }
      function setupAutoConvertInput(input, useFileIdOnly = false) {
        input.addEventListener('input', function() {
          let url = this.value.trim();
          if (url) {
            let convertedUrl = convertDriveUrl(url, useFileIdOnly);
            if (convertedUrl !== url) {
              this.value = convertedUrl;
              showStatus('Googleドライブリンクを自動変換しました', 'success');
            }
          }
        });
      }
      function getPreviewUrl(inputValue) {
        if (!inputValue) return null;
        if (inputValue.startsWith('http') && !inputValue.includes('drive.google.com')) return inputValue;
        if (/^[a-zA-Z0-9_-]{25,}$/.test(inputValue)) return 'https://drive.google.com/file/d/' + inputValue + '/view';
        const match = inputValue.match(/[?&]id=([a-zA-Z0-9_-]+)/);
        if (match && match[1]) return 'https://drive.google.com/file/d/' + match[1] + '/view';
        return inputValue;
      }
      document.querySelectorAll('.btn-secondary').forEach(button => {
        if (button.id === 'openFolderBtn' || button.id === 'cancelGenerationBtn' || button.id === 'closePresetModal' || button.id === 'presetMenuButton') return;
        button.addEventListener('click', function() {
          const input = this.parentElement.querySelector('input[type="text"]');
          if (input) {
            const inputValue = input.value.trim();
            if (inputValue) {
              const previewUrl = getPreviewUrl(inputValue);
              if (previewUrl) { window.open(previewUrl, '_blank', 'noopener,noreferrer'); } 
              else { alert('プレビューできない形式です'); }
            } else { alert('URLを入力してください'); }
          }
        });
      });
      try {
        const splitter = $('splitter');
        const appLayout = $('app-layout');
        const leftPane = $('left-pane');
        const rightPane = $('slidePreviewContainer');
        let isDragging = false;
        const minWidth = 300;
        splitter.addEventListener('mousedown', (e) => {
          e.preventDefault();
          isDragging = true;
          splitter.classList.add('dragging');
          document.body.style.userSelect = 'none';
          leftPane.style.pointerEvents = 'none';
          rightPane.style.pointerEvents = 'none';
        });
        document.addEventListener('mousemove', (e) => {
          if (!isDragging) return;
          e.preventDefault();
          const splitterWidth = splitter.offsetWidth;
          const totalWidth = window.innerWidth;
          let leftPaneWidth = e.clientX;
          let rightPaneWidth = totalWidth - leftPaneWidth - splitterWidth;
          if (leftPaneWidth < minWidth) {
            leftPaneWidth = minWidth;
            rightPaneWidth = totalWidth - leftPaneWidth - splitterWidth;
          } else if (rightPaneWidth < minWidth) {
            rightPaneWidth = minWidth;
            leftPaneWidth = totalWidth - rightPaneWidth - splitterWidth;
          }
          const leftFr = (leftPaneWidth / totalWidth) * 100;
          const rightFr = (rightPaneWidth / totalWidth) * 100;
          const newRatio = `${leftFr}fr ${splitterWidth}px ${rightFr}fr`;
          appLayout.style.gridTemplateColumns = newRatio;
        });
        document.addEventListener('mouseup', () => {
          if (isDragging) {
            isDragging = false;
            splitter.classList.remove('dragging');
            document.body.style.userSelect = '';
            leftPane.style.pointerEvents = '';
            rightPane.style.pointerEvents = '';
            lastSplitRatio = appLayout.style.gridTemplateColumns; 
          }
        });
      } catch(e) {
        console.error('スプリッターの初期化に失敗:', e);
      }
      const toggleFullscreenBtn = $('toggleFullscreenBtn');
      if (toggleFullscreenBtn) {
         toggleFullscreenBtn.addEventListener('click', toggleFullscreenPreview);
      }



      try {
        const leftPane = $('left-pane');
        const mainGridInLeftPane = leftPane.querySelector('.main-grid');
        const bodyEl = document.body;

        // ▼ [デバッグ修正] レスポンシブ判定ロジック ▼
        window.adjustResponsiveLayout = function() {
            const leftPane = document.getElementById('left-pane');
            const bodyEl = document.body;
            
            if (!leftPane || !bodyEl) {
                console.error('[Debug Layout] Elements not found');
                return;
            }

            // 現在の幅を取得
            const rect = leftPane.getBoundingClientRect();
            const paneWidth = rect.width;
            
            console.log(`[Debug Layout] Width: ${paneWidth}px, Classes: ${leftPane.className}`); // ★デバッグログ

            const verticalThreshold = 992;
            if (paneWidth < verticalThreshold) {
              if (!leftPane.classList.contains('vertical-layout')) {
                  console.log('[Debug Layout] Adding vertical-layout'); // ★デバッグログ
                  leftPane.classList.add('vertical-layout');
              }
            } else {
              if (leftPane.classList.contains('vertical-layout')) {
                  console.log('[Debug Layout] Removing vertical-layout'); // ★デバッグログ
                  leftPane.classList.remove('vertical-layout');
              }
            }

            // 狭幅モード判定（ヒステリシス付き）
            const currentIsNarrow = leftPane.classList.contains('narrow-layout');
            const narrowThresholdEnter = 650; 
            const narrowThresholdExit = 670; 

            if (!currentIsNarrow && paneWidth < narrowThresholdEnter) { 
              console.log('[Debug Layout] Entering narrow mode'); // ★デバッグログ
              leftPane.classList.add('narrow-layout');
              bodyEl.classList.add('narrow-layout');
              document.documentElement.style.setProperty('--bg-body', '#F9FAFB');
              document.documentElement.style.setProperty('--card-header-bg', '#F9FAFB');
            } else if (currentIsNarrow && paneWidth > narrowThresholdExit) {
              console.log('[Debug Layout] Exiting narrow mode'); // ★デバッグログ
              leftPane.classList.remove('narrow-layout');
              bodyEl.classList.remove('narrow-layout');
              
              if (typeof updateBackgroundColor === 'function') {
                  const bgInput = document.getElementById('backgroundColorText');
                  if (bgInput) updateBackgroundColor(bgInput.value);
              }
            }
        };
        // ▲ [デバッグ修正] ここまで ▲

        if (window.ResizeObserver && mainGridInLeftPane && bodyEl) {
          const observer = new ResizeObserver(entries => {
            // ResizeObserver検知時に共通関数を実行
            window.adjustResponsiveLayout();
          });
          observer.observe(leftPane);
        }
      } catch(e) {


        console.error('ResizeObserverの初期化に失敗:', e);
      }
      document.querySelectorAll('#titleBgUrl, #sectionBgUrl, #mainBgUrl, #closingBgUrl, #headerLogoUrl, #closingLogoUrl').forEach(input => {
        setupAutoConvertInput(input, true);
      });
      try {
        const appLayout = $('app-layout');
        const leftPane = $('left-pane');
        const previewPane = $('slidePreviewContainer');
        const splitter = $('splitter');
        const closePreviewBtn = $('closePreviewBtn');
        const closeSettingsBtn = $('closeSettingsBtn');
        if (closePreviewBtn) {
          closePreviewBtn.addEventListener('click', () => {
            hidePreviewPane(); 
          });
        }
        if (closeSettingsBtn) {
          closeSettingsBtn.addEventListener('click', () => {
            if (!isSlideFullscreen) {
              if (!isPreviewVisible) {
                if (!lastGeneratedSlideUrl) {
                   showStatus('エラー: 表示するスライドがありません。', 'error');
                   return;
                }
                let embedUrl = lastGeneratedSlideUrl; // ★編集可能プレビュー(@47): /editのまま(以前は/embed化していた)
                $('slidePreviewFrame').src = embedUrl;
                previewPane.style.display = 'block';
                splitter.style.display = 'block';
                $('fullscreenToggleContainer').style.display = 'block';
                appLayout.style.gridTemplateColumns = lastSplitRatio;
                isPreviewVisible = true;
              }
              toggleFullscreenPreview();
            }
          });
        }
      } catch(e) {
        console.error('パネルクローズボタンの初期化に失敗:', e);
      }
      const closeJsonErrorModalBtn = $('closeJsonErrorModalBtn');
      const copyJsonErrorBtn = $('copyJsonErrorBtn');
      const jsonErrorModal = $('jsonErrorModal');
      if (closeJsonErrorModalBtn) {
        closeJsonErrorModalBtn.addEventListener('click', closeJsonErrorModal);
      }
      if (copyJsonErrorBtn) {
        copyJsonErrorBtn.addEventListener('click', copyJsonError);
      }
      if (jsonErrorModal) {
        jsonErrorModal.addEventListener('click', (e) => {
          if (e.target === jsonErrorModal) {
            closeJsonErrorModal();
          }
        });
      }
      const confirmClearBtn = $('confirmClearBtn');
      const cancelClearBtn = $('cancelClearBtn');
      const clearConfirmModal = $('clearConfirmModal');
      if (confirmClearBtn) {
        confirmClearBtn.addEventListener('click', performClear);
      }
      if (cancelClearBtn) {
        cancelClearBtn.addEventListener('click', closeClearConfirmModal);
      }
      if (clearConfirmModal) {
        clearConfirmModal.addEventListener('click', (e) => {
          if (e.target === clearConfirmModal) {
            closeClearConfirmModal();
          }
        });
      }

      const cancelDeleteItemBtn = $('cancelDeleteItemBtn');
      const deleteItemConfirmModal = $('deleteItemConfirmModal');
      
      if (cancelDeleteItemBtn) {
        // 「キャンセル」ボタン
        cancelDeleteItemBtn.addEventListener('click', closeDeleteItemConfirmModal);
      }
      if (deleteItemConfirmModal) {
        // モーダルの背景クリック
        deleteItemConfirmModal.addEventListener('click', (e) => {
          if (e.target === deleteItemConfirmModal) {
            closeDeleteItemConfirmModal();
          }
        });
      }

      const showPreviewBtnLeft = $('showPreviewBtnLeft');
      if (showPreviewBtnLeft) {
        showPreviewBtnLeft.addEventListener('click', () => {
          if (lastGeneratedSlideUrl) {
            showPreviewPane();
            if (SlideEditor && SlideEditor.isEditorActive()) {
              SlideEditor.updatePreviewButtonForEditor(true);
            }
          } else {
            showStatus('エラー: 表示するスライドがありません。', 'error');
          }
        });
      }
      
      // ▼ [変更] termsModal の閉じるロジック (通常時用) ▼
      try {
        const termsModal = $('termsModal');
        const closeTermsModalBtn = $('closeTermsModalBtn'); // 通常時用の閉じるボタン

        function closeTermsModal() {
          if (termsModal) {
            termsModal.style.display = 'none';
            // iframeの読み込みを停止（リソース解放）
            const iframe = $('termsFrame');
            if (iframe) {
              // iframe.src = 'about:blank'; // ◀ 読み込みを停止すると中央揃えが崩れることがあるためコメントアウト
            }
          }
        }

        if (closeTermsModalBtn) {
          closeTermsModalBtn.addEventListener('click', closeTermsModal);
        }
        if (termsModal) {
          termsModal.addEventListener('click', (e) => {
            if (e.target === termsModal) { // オーバーレイ背景をクリック
              closeTermsModal();
            }
          });
        }
      } catch(e) {
        console.error('利用規約モーダルの閉じるイベント設定に失敗:', e);
      }
      // ▲ [変更] ここまで ▲
    }
    
    // ▼▼▼ [新規追加] 起動ロジックの分岐 ▼▼▼
    let activationData;
    try {
      // doGet から渡された activationData を読み込む
      activationData = <?!= JSON.stringify(activationData) ?>;
} catch (e) {
      console.error("アクティベーションデータの読み込みに失敗", e);
      activationData = { isActivated: false, userEmail: null }; // フォールバック
    }

    if (ENABLE_CREDIT_IMAGE === false || activationData.isActivated) {
      // 【A】アクティベート済みの場合：すぐにアプリを初期化
      console.log('ユーザーはアクティベート済みです。アプリを起動します。');
      initializeAppLogic();
    } else {
      // 【B】初回起動の場合：アクティベーションフローを開始
      console.log('初回起動です。アクティベーションフローを開始します。');
      // フローが完了したら、initializeAppLogic を実行するようコールバックを渡す
      ActivationFlow.init(activationData.userEmail, initializeAppLogic);
      ActivationFlow.start();
    }
    // ▲▲▲ [新規追加] 起動ロジックここまで ▲▲▲

    google.script.run
      .withSuccessHandler((historyItems) => {
        console.log('[Preload] 履歴データを裏側で取得しました:', historyItems);
        cachedHistoryList = historyItems; // キャッシュに保存
      })
      .withFailureHandler((e) => {
        console.warn('[Preload] 履歴の先読みに失敗しました:', e);
      })
      .getUserHistory();




    // ▼▼▼ [追加] スライドID入力欄のダブルクリック自動ペースト機能 ▼▼▼
    const historyInput = $('historyInputId');
    if (historyInput) {
        historyInput.addEventListener('dblclick', async function() {
            try {
                // 1. クリップボードからテキストを読み取る
                const text = await navigator.clipboard.readText();
                if (!text) {
                    showStatus('クリップボードが空です', 'info');
                    return;
                }

                // 2. テキストエリアに入力して表示
                this.value = text;
                showStatus('クリップボードから貼り付けました。検証中...', 'info', 2000);

                // 3. 検証と読み込みを実行 (openHistoryModal内で定義したヘルパー関数を利用)
                // ※ validateAndLoadId はグローバルスコープまたはアクセス可能な場所に定義する必要があります。
                // 上記の修正で openHistoryModal の外に出すか、以下のように直接ロジックを書きます。
                
                // --- 検証ロジック (validateAndLoadId と同じ内容) ---
                let rawVal = text.trim();
                let targetId = null;
                const urlPatterns = [
                    /\/d\/([a-zA-Z0-9_-]+)/,
                    /id=([a-zA-Z0-9_-]+)/,
                    /\/file\/d\/([a-zA-Z0-9_-]+)/
                ];
                for (const p of urlPatterns) {
                    const m = rawVal.match(p);
                    if (m && m[1]) { targetId = m[1]; break; }
                }
                if (!targetId && /^[a-zA-Z0-9_-]{25,}$/.test(rawVal)) {
                    targetId = rawVal;
                }

                if (targetId) {
                    // 成功時
                    showStatus('IDを認識しました。読み込みを開始します...', 'success');
                    executeRestore(targetId);
                } else {
                    // 失敗時
                    showStatus('無効なURLまたはスライドID形式です', 'error');
                    this.value = ''; // テキストを削除
                }
                // -------------------------------------------------

            } catch (err) {
                console.warn('Clipboard read failed:', err);
                showStatus('クリップボードの読み取りに失敗しました', 'error');
            }
        });
    }
    // ▲▲▲ [追加] ここまで ▲▲▲

    // ▼▼▼ [新規追加] アプリ起動時にOCRエンジンをバックグラウンドで「暖気運転」しておく ▼▼▼
    // これにより、ユーザーが画像分解ツールを開いたときには既にワーカーが待機状態になります。
    setTimeout(() => {
        if (typeof initTesseractScheduler === 'function') {
            console.log("[App] OCRエンジンの事前起動を開始します...");
            initTesseractScheduler().then(() => {
                console.log("[App] OCRエンジンがスタンバイ完了しました。");
            }).catch(e => {
                console.warn("[App] OCRエンジンの事前起動に失敗 (必要時に再試行されます):", e);
            });
        }
    }, 1000); // 描画負荷を避けるため1秒後に開始
    // ▲▲▲ [追加] ここまで ▲▲▲

});







  // --- 履歴復元機能 (モーダル版) ---
  const historyBtn = document.getElementById('historyRestoreBtn');
  
  if (historyBtn) {
    historyBtn.addEventListener('click', function() {
      openHistoryModal();
    });
  }



  /**
   * 履歴モーダルを開く (ローカルストレージキャッシュ対応版)
   */
  function openHistoryModal() {
    console.log('[Client Debug] openHistoryModal called');
    const modal = $('historyRestoreModal');
    const input = $('historyInputId');
    const listContainer = $('historyListContainer');
    const loadBtn = $('historyLoadBtn');
    const closeBtn = $('historyCloseBtn');
    
    // ローカルストレージのキー定義
    const STORAGE_KEY_HISTORY = 'slideFactory_history_cache_v1';

    if (!modal) return;

    // 初期化
    input.value = '';
    if (lastGeneratedSlideUrl) {
       const match = lastGeneratedSlideUrl.match(/\/d\/([a-zA-Z0-9_-]+)/);
       if (match && match[1]) input.value = match[1];
    }
    
    modal.style.display = 'flex';

    // ▼▼▼ [修正] ローカルストレージ優先表示ロジック ▼▼▼
    
    // 1. まずローカルストレージを確認して即時表示
    const cachedJson = localStorage.getItem(STORAGE_KEY_HISTORY);
    if (cachedJson) {
        try {
            const cachedItems = JSON.parse(cachedJson);
            if (Array.isArray(cachedItems) && cachedItems.length > 0) {
                console.log('[Client] ローカルストレージから履歴を表示します');
                renderHistoryList(cachedItems);
            } else {
                // キャッシュが空配列の場合
                listContainer.innerHTML = '<div style="padding: 2rem; text-align: center; color: var(--text-light);">履歴を読み込み中...</div>';
            }
        } catch (e) {
            console.warn('ローカルストレージのパース失敗:', e);
            listContainer.innerHTML = '<div style="padding: 2rem; text-align: center; color: var(--text-light);">履歴を読み込み中...</div>';
        }
    } else {
        // キャッシュが全くない場合
        listContainer.innerHTML = '<div style="padding: 2rem; text-align: center; color: var(--text-light);">履歴を読み込み中...</div>';
    }

    // 2. サーバーから最新データを取得して更新（Stale-while-revalidate）
    google.script.run
      .withSuccessHandler((historyItems) => {
         console.log('[Client] サーバーから最新履歴を取得しました');
         // データをローカルストレージに保存
         localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(historyItems));
         // リストを再描画（最新化）
         renderHistoryList(historyItems);
      })
      .withFailureHandler((e) => {
         console.error('履歴取得エラー:', e);
         // キャッシュが表示されていればエラーはトーストで出すだけに留める
         if (!cachedJson) {
             listContainer.innerHTML = `<div style="padding: 1rem; color: var(--danger-color);">履歴の取得に失敗しました: ${e.message}</div>`;
         } else {
             showStatus('最新の履歴取得に失敗しました（キャッシュを表示中）', 'error');
         }
      })
      .getUserHistory();
    // ▲▲▲ [修正] ここまで ▲▲▲

    // イベント設定
    closeBtn.onclick = () => { modal.style.display = 'none'; };
    modal.onclick = (e) => { if (e.target === modal) modal.style.display = 'none'; };
    
    // 読み込みボタンの処理（共通化のため関数を使用）
    loadBtn.onclick = () => {
      validateAndLoadId(input.value);
    };
  }

  /**
   * ID検証と読み込み実行を行うヘルパー関数
   */
  function validateAndLoadId(rawVal) {
      rawVal = rawVal.trim();
      if (!rawVal) {
        showStatus('IDまたはURLを入力してください', 'error');
        return false;
      }

      let targetId = null;
      
      // パターン1: Google Drive/Slides URL
      const urlPatterns = [
        /\/d\/([a-zA-Z0-9_-]+)/,
        /id=([a-zA-Z0-9_-]+)/,
        /\/file\/d\/([a-zA-Z0-9_-]+)/
      ];
      
      for (const p of urlPatterns) {
        const m = rawVal.match(p);
        if (m && m[1]) {
          targetId = m[1];
          break;
        }
      }

      // パターン2: 純粋なID形式 (25文字以上の英数記号)
      if (!targetId) {
          if (/^[a-zA-Z0-9_-]{25,}$/.test(rawVal)) {
              targetId = rawVal;
          }
      }

      if (targetId) {
          // 成功: 読み込み実行
          executeRestore(targetId);
          return true;
      } else {
          // 失敗: エラー表示してテキストをクリア
          showStatus('無効なURLまたはスライドID形式です', 'error');
          const input = $('historyInputId');
          if(input) {
              input.value = ''; // テキスト削除
              input.focus();
          }
          return false;
      }
  }



    /**
   * ★追加: クライアント側で相対時間を計算する関数
   */
  function getRelativeTimeClient(timestamp) {
    if (!timestamp) return '';
    const now = new Date();
    const date = new Date(timestamp);
    const diffMs = now.getTime() - date.getTime();
    const diffMins = Math.floor(diffMs / 60000);

    if (diffMins < 1) return 'たった今';
    if (diffMins < 60) return `${diffMins}分前`;
    const diffHours = Math.floor(diffMins / 60);
    if (diffHours < 24) return `${diffHours}時間前`;
    const diffDays = Math.floor(diffHours / 24);
    return `${diffDays}日前`;
  }




  /**
   * HTMLエスケープ用ヘルパー関数
   * (renderHistoryListなどで使用するためグローバルに定義)
   */
  function escapeHTML(str) {
    if (str === null || typeof str === 'undefined') return '';
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }


  /**
   * 履歴リストの描画 (デバッグログ付き)
   */
  function renderHistoryList(historyItems) {
    console.log('[Client Debug] renderHistoryList called. Item count:', historyItems ? historyItems.length : 'null'); // ★ログ

    const container = $('historyListContainer');
    if (!historyItems || historyItems.length === 0) {
      console.log('[Client Debug] History is empty.'); // ★ログ
      container.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--text-secondary);">履歴はありません</div>`;
      return;
    }

    let html = '';
    try {
      historyItems.forEach((item, index) => {
        // データの整合性チェック
        if (!item || !item.id) {
          console.warn(`[Client Debug] Invalid item at index ${index}:`, item); // ★ログ
          return;
        }

        const name = escapeHTML(item.name || '名称未設定');
        let dateStr = escapeHTML(item.lastModified || '-');
        let timestamp = item.timestamp;

        // timestampがない古いデータの場合、文字列から無理やり復元を試みる
        if (!timestamp && dateStr.includes('(')) {
             // "2023/11/25 10:00 (たった今)" のような形式から日付部分だけ抽出
             const cleanDateStr = dateStr.split('(')[0].trim();
             const d = new Date(cleanDateStr);
             if (!isNaN(d.getTime())) {
                 timestamp = d.getTime();
                 dateStr = cleanDateStr; // 表示用日付もきれいにする
             }
        }

        // 相対時間をリアルタイム計算して付与
        if (timestamp) {
            const relTime = getRelativeTimeClient(timestamp);
            dateStr = `${dateStr} <span style="color: var(--primary-color); font-size: 0.9em;">(${relTime})</span>`;
        }

        const date = dateStr; // 変数名を合わせる
        const editor = escapeHTML(item.editor || '-');

        const id = escapeHTML(item.id);

        html += `
          <div class="history-item" onclick="executeRestore('${id}')" title="クリックして復元">
            <div class="history-item-title">
              <span class="history-tag">SLIDE</span> ${name}
            </div>
            <div class="history-item-meta">
              <span>📅 ${date}</span>
              <span style="max-width: 150px; overflow: hidden; text-overflow: ellipsis;">👤 ${editor}</span>
            </div>
          </div>
        `;
      });
      container.innerHTML = html;
      console.log('[Client Debug] History list rendered to DOM.'); // ★ログ
    } catch (e) {
      console.error('[Client Debug] Error during renderHistoryList loop:', e); // ★ログ
      container.innerHTML = `<div style="padding: 1rem; color: red;">描画エラーが発生しました: ${e.message}</div>`;
    }
  }






  /**
   * 読み込み処理の実行 (修正版: URL設定順序を最適化)
   */
  window.executeRestore = function(presentationId) {
    const modal = $('historyRestoreModal');
    if (modal) modal.style.display = 'none'; // モーダルを閉じる

    // ▼▼▼ [修正] トーストIDを取得し、時間を十分に長くする (例: 5分) ▼▼▼
    const loadingToastId = showStatus('履歴データを読み込んでいます...', 'info', 300000); 
    // ▲▲▲ [修正] ここまで ▲▲▲

    google.script.run
      .withSuccessHandler(function(response) {
        
        // ▼▼▼ [追加] 処理完了時にロード中トーストを消す ▼▼▼
        if (loadingToastId) {
            const el = document.getElementById(loadingToastId);
            if (el) el.remove();
        }
        // ▲▲▲ [追加] ここまで ▲▲▲

        if (response.status === 'success') {
          const historyData = response.data;
          const savedSettings = historyData.settings;
          const savedSlideData = historyData.slideData;
          const url = response.url;

          console.log("読み込みデータ取得:", historyData);

          // ★★★ [重要] ここで先にURLを確定させます ★★★
          // これにより、後続の validateJSON() -> enterEditorMode() が走った際に、
          // 最初から「更新モード」として正しくボタンが表示されます。
          lastGeneratedSlideUrl = url;
          
          // A. 設定の適用
          if (savedSettings) {
            applyAllSettings(savedSettings);
          }

          // B. スライドデータの適用
          if (savedSlideData) {


            // 前処理 (バックアップ画像の復元など)
            savedSlideData.forEach(slide => {
              // ★★★ 修正: 複数のバックアップキーを復元 ★★★
              const keysToRestore = ['Image', 'BackgroundImage', 'ForegroundImage'];
              
              keysToRestore.forEach(suffix => {
                  const backupKey = '_original' + suffix;
                  const targetKey = suffix.charAt(0).toLowerCase() + suffix.slice(1);
                  
                  if (slide[backupKey]) {
                    // ▼▼▼ 追加チェック ▼▼▼
                    // 現在の値(targetKey)がBase64画像やオブジェクトデータを持っているか確認
                    const currentValue = slide[targetKey];
                    const hasImageData = (
                        (typeof currentValue === 'string' && currentValue.startsWith('data:image/')) ||
                        (typeof currentValue === 'object' && currentValue !== null && (currentValue.data || currentValue.bgData))
                    );

                    // 画像データが無い場合のみ、元のテキスト(NotebookLM1など)に戻す
                    if (!hasImageData) {
                        slide[targetKey] = slide[backupKey];
                    }
                    // ▲▲▲ 追加チェックここまで ▲▲▲
                    
                    //delete slide[backupKey]; // バックアップキーは削除（不要な場合）
                  }
              });
              if (slide.ghostImageBase64) delete slide.ghostImageBase64;
            });



            const jsonString = JSON.stringify(savedSlideData, null, 2);
            const textarea = $('slideDataInput');
            
            // エディタのリセット (既存の編集状態をクリア)
            if (typeof SlideEditor !== 'undefined' && SlideEditor.reset) {
               SlideEditor.reset();
            }

            textarea.value = jsonString;

            if (typeof validateJSON === 'function') {
                // まずバリデーション（エラーチェック）
                // ※ここで自動的に enterEditorMode が呼ばれないようにフラグ制御しても良いが、
                //   validateJSON内の enterEditorMode 呼び出し条件によっては二度手間になるため、
                //   明示的に setupForUpdate を呼ぶ形にする。
                
                // 一旦テキストエリアの値を検証（エラーがあればモーダルが出る）
                validateJSON(); 
            }
            
            // エディタを更新モードで起動
            if (typeof SlideEditor !== 'undefined' && SlideEditor.setupForUpdate) {
               SlideEditor.setupForUpdate();
            }




            if (typeof SlideEditor !== 'undefined' && SlideEditor.autoResizeTextarea) {
               SlideEditor.autoResizeTextarea(textarea);
            }
          }

          // C. プレビュー更新
          if (lastGeneratedSlideUrl) {
              // 新規タブボタンの更新
              const newTabBtn = $('openSlideNewTabBtn');
              if(newTabBtn) {
                  newTabBtn.href = lastGeneratedSlideUrl;
                  newTabBtn.style.display = 'inline-flex';
              }
              
              // プレビュー表示
              if (typeof showPreviewPane === 'function') {
                  showPreviewPane();

                  if (typeof SlideEditor !== 'undefined' && SlideEditor.updatePreviewButtonForEditor) {
                      SlideEditor.updatePreviewButtonForEditor(true);
                  }

              }
              
              // iframeリロード
              const iframe = $('slidePreviewFrame');
              if (iframe) {
                  let previewSrc = lastGeneratedSlideUrl; // ★編集可能プレビュー(@47): /editのまま(以前は/embed化して閲覧専用にしていた)
                  // URLが変更されている場合のみリロード
                  if (iframe.src !== previewSrc) {
                      iframe.src = previewSrc;
                  }
              }
          }
          
          // 成功したので、このIDを履歴の先頭に持ってくる
          google.script.run.withFailureHandler(function(e){ console.warn('[history] addToUserHistory失敗:', e && e.message); }).addToUserHistory(presentationId);

          showStatus(`日時: ${historyData.dateStr} のデータを読み込みました。`, 'success');

          setTimeout(() => {
              startBackgroundPreloading();
          }, 1000);

        } else {
          showStatus('エラー: ' + response.message, 'error');
        }
      })
      .withFailureHandler(function(e) {

        if (loadingToastId) {
            const el = document.getElementById(loadingToastId);
            if (el) el.remove();
        }

        console.error('[server] エラー:', e); showStatus('サーバーでエラーが発生しました', 'error');
      })
      .loadLatestGenerationState(presentationId);
  };









    /**
     * 画像更新オプションを選択させるモーダルを表示し、結果をPromiseで返します。
     * @returns {Promise<string>} 'keep' または 'update' を resolve する Promise。キャンセル時は reject する。
     */
    function promptImageUpdateOption() {
      return new Promise((resolve, reject) => {
        const modal = $('imageUpdateOptionModal');
        const updateBtn = $('imageOptionUpdateBtn');
        const keepBtn = $('imageOptionKeepBtn');
        const cancelBtn = $('imageOptionCancelBtn');

        // イベントリスナーを一度だけ設定するためのフラグ
        let listenersAttached = false;

        function cleanup() {
          updateBtn.removeEventListener('click', handleUpdate);
          keepBtn.removeEventListener('click', handleKeep);
          cancelBtn.removeEventListener('click', handleCancel);
          modal.style.display = 'none';
        }

        function handleUpdate() {
          cleanup();
          resolve('update'); // 画像も更新する
        }
        function handleKeep() {
          cleanup();
          resolve('keep'); // 画像はそのまま使う
        }
        function handleCancel() {
          cleanup();
          reject(new Error('ユーザーによって操作がキャンセルされました。')); // キャンセル
        }

        if (!listenersAttached) {
            updateBtn.addEventListener('click', handleUpdate);
            keepBtn.addEventListener('click', handleKeep);
            cancelBtn.addEventListener('click', handleCancel);
            listenersAttached = true;
        }

        modal.style.display = 'flex'; // モーダルを表示
      });
    }

    /**
     * 16進数カラーコードからfeColorMatrixのvalues文字列を計算します
     * (ユーザー提供のロジック)
     * @param {string} hex - #RRGGBB 形式のカラーコード
     * @returns {string} feColorMatrixのvalues属性用の文字列
     */
    function calculateColorMatrixValues(hex) {
      const rgb = hexToRgb(hex); // 既存のhexToRgbヘルパーを利用
      if (!rgb) {
        // 不正な色の場合はグレーのまま (フォールバック)
        return "0.33 0.33 0.33 0 0 0.33 0.33 0.33 0 0 0.33 0.33 0.33 0 0 0 0 0 1 0";
      }
      
      // 0-1の範囲に正規化
      const rNorm = rgb.r / 255;
      const gNorm = rgb.g / 255;
      const bNorm = rgb.b / 255;

      // 輝度(0.33)に乗算
      const rVal = (0.33 * rNorm).toFixed(4);
      const gVal = (0.33 * gNorm).toFixed(4);
      const bVal = (0.33 * bNorm).toFixed(4);

      // matrixのvalues文字列を生成
      return [
        rVal, rVal, rVal, 0, 0,
        gVal, gVal, gVal, 0, 0,
        bVal, bVal, bVal, 0, 0,
        0, 0, 0, 1, 0
      ].join(' ');
    }


/**
     * ★ 修正版 (ユーザーリクエスト適用): ゴースト番号のSVG文字列を生成する
     * (feColorMatrixをfrontColorから計算)
     * @param {string} number - "01" などの2桁の数字
     * @param {number} width - SVGの幅
     * @param {number} height - SVGの高さ
     * @param {Object} settings - フィルター設定
     * @returns {string} SVGのXML文字列
     */
    function generateGhostNumberSvg(number, width, height, settings) {
      // 毎回異なるムラにするため、ランダムなseedを生成
      const seed = Math.floor(Math.random() * 1000);
      // フォントサイズを大きく (高さの100%)
      const fontSize = Math.floor(height * 1.0);

      // フィルター設定の展開
      const {
        baseFrequency,
        // colorType, // 削除
        alphaSlope,
        contrastSlope,
        frontColor,
        frontAlpha // frontAlphaは現在使用されていないが、念のため残す
      } = settings;

      const contrastIntercept = (0.5 * (1 - contrastSlope)).toFixed(2);

      // ★★★ ここから変更 ★★★
      // 1. frontColorからmatrixのvaluesを計算
      const matrixValues = calculateColorMatrixValues(frontColor);

      // 2. feColorMatrixのSVG文字列を定義
      const colorMatrixFilter = `
    <feColorMatrix in="noise" type="matrix"
        values="${matrixValues}"
        result="coloredNoise"/>`;
        
      // 3. feCompositeの入力(in)は "coloredNoise" に固定
      const compositeIn = 'coloredNoise';
      // ★★★ 変更ここまで ★★★

      // SVG文字列を構築
      const svgString = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>
    <filter id="ghostNoiseFilter" x="0" y="0" width="100%" height="100%">
      <feTurbulence
          type="fractalNoise"
          baseFrequency="${baseFrequency}"
          numOctaves="3"
          seed="${seed}"
          result="noise"/>
      ${colorMatrixFilter}
      <feComposite in="${compositeIn}" in2="SourceAlpha" operator="in" result="maskedNoise"/>
      <feComponentTransfer in="maskedNoise" result="finalTexture">
          <feFuncR type="linear" slope="${contrastSlope}" intercept="${contrastIntercept}"/>
          <feFuncG type="linear" slope="${contrastSlope}" intercept="${contrastIntercept}"/>
          <feFuncB type="linear" slope="${contrastSlope}" intercept="${contrastIntercept}"/>
          <feFuncA type="linear" slope="${alphaSlope}" intercept="0"/>
      </feComponentTransfer>
      <feMerge>
        <feMergeNode in="finalTexture"/>
      </feMerge>
    </filter>
  </defs>

  <text
      x="43%"
      y="50%"
      dominant-baseline="middle"
      text-anchor="middle"
      font-size="${fontSize}"
      font-family="'Verdana', sans-serif"  font-weight="bold"  fill="${frontColor}"
      filter="url(#ghostNoiseFilter)" fill-opacity="0.45">
      ${number}
  </text>

  <text
      x="43%"
      y="50%"
      dominant-baseline="middle"
      text-anchor="middle"
      font-size="${fontSize}"
      font-family="'Verdana', sans-serif"  font-weight="bold"  fill="${frontColor}"
      fill-opacity="0.1">
      ${number}
  </text>
</svg>`;

      return svgString;
    }

    /**
     * クレジット画像用のSVG文字列を生成する (ユーザー指定のSVGテンプレートベース)
     * @param {string} text - "Change the world　majin" などのクレジットテキスト
     * @param {string} primaryColor - ユーザーが選択したプライマリカラー (例: #4285F4)
     * @returns {string} SVGのXML文字列
     */
    function generateCreditSvg(text, primaryColor) {
      // 毎回異なるノイズにするため、ランダムなseedを生成
      const seed = Math.floor(Math.random() * 1000);
      
      // 既存のヘルパー関数を使い、プライマリカラーから feColorMatrix の値を計算
      const matrixValues = calculateColorMatrixValues(primaryColor);
      
      // テキストをHTMLエスケープ
      const escapedText = (text || '')
          .replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;');

      // ユーザー提供のSVGテンプレートにプレースホルダーを埋め込む
      // (注: dropShadowEffect フィルターは元SVGで定義されていなかったため <g> の filter 属性を削除)
      const svgString = `
<svg width="500" height="50" viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>
    <filter id="ghostNoiseFilter_Strong" x="0" y="0" width="100%" height="100%">
      <feTurbulence
          type="turbulence"
          baseFrequency="0.7"
          numOctaves="3"
          seed="${seed}" 
          result="noise"/>
      <feColorMatrix in="noise" type="matrix"
        values="${matrixValues}"
        result="coloredNoise"/>
      <feComposite in="coloredNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/>
      <feComponentTransfer in="maskedNoise" result="finalTexture">
          <feFuncA type="linear" slope="1.0" intercept="0"/> </feComponentTransfer>
      <feMerge>
        <feMergeNode in="finalTexture"/>
      </feMerge>
     </filter>
    <linearGradient id="textGradient" x1="0%" y1="0%" x2="30%" y2="100%">
      <stop offset="0%" style="stop-color:#ffffff; stop-opacity:1" />
      <stop offset="100%" style="stop-color:${primaryColor}; stop-opacity:1" />
    </linearGradient>
  </defs>
  <g>
    <text
        x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
        font-size="10pt" font-family="'Verdana', sans-serif" font-weight="bold" 
        fill="url(#textGradient)" filter="url(#ghostNoiseFilter_Strong)" fill-opacity="0.8">
        ${escapedText}
    </text>
    <text
        x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
        font-size="10pt" font-family="'Verdana', sans-serif" font-weight="bold" 
        fill="url(#textGradient)" fill-opacity="0.4">
        ${escapedText}
    </text>
  </g>
</svg>`;
      
      return svgString;
    }

    /**
     * ★ 修正版: slideDataをループし、全セクションのゴースト画像を生成する
     * (文字列の数字、および空欄時の透明画像に対応)
     * @param {Array} slideData - パース済みのスライドデータ配列
     * @param {Object} ghostSettings - SVGフィルター設定
     * @returns {Promise<Array>} Base64画像が埋め込まれたslideData配列
     */
    async function generateGhostNumberImages(slideData, ghostSettings) {
      // GAS側のCONFIG.POS_PX.sectionSlide.ghostNum の値
      const GHOST_WIDTH = 400; 
      const GHOST_HEIGHT = 250;

      let sectionCounter = 0;
      const promises = []; 
      
      // 1x1 透明PNGのBase64データ
      const TRANSPARENT_PNG_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';

      for (const slide of slideData) {
        if (slide.type === 'section') {
          sectionCounter++; // まずカウンターを増やす (フォールバック用)
          
          const sectionNo = slide.sectionNo;
          const sectionNoString = String(sectionNo || '').trim();

          // 1. sectionNo が null, undefined, または空文字列 ("") かどうかをチェック
          if (sectionNo === null || sectionNo === undefined || sectionNoString === '') {
              
              // 2. 空欄の場合は、透明なPNGを同期的に設定
              console.log('[generate] sectionNo が空のため、透明なゴースト画像を挿入します。');
              slide.ghostImageBase64 = TRANSPARENT_PNG_BASE64;
              // (非同期 promise はプッシュしない)

          } else {
              // 3. 空欄でない場合のみ、既存のゴースト番号生成ロジックを実行
              
              // (前回の修正を適用した) parsedNum ロジック
              const parsedNum = (() => {
                  // (sectionNo は上で既に定義済み)
                  if (Number.isFinite(sectionNo)) { return Number(sectionNo); }
                  
                  // (sectionNoString は上でトリム済み)
                  if (typeof sectionNo === 'string' || typeof sectionNo === 'number') {
                      const parsedStringNum = parseFloat(sectionNoString);
                      if (Number.isFinite(parsedStringNum)) {
                          return parsedStringNum;
                      }
                  }
                  
                  // sectionNo が無効な場合、タイトルから解析を試みる
                  const m = String(slide.title || '').match(/^\s*(\d+)[\.．]/);
                  if (m && m[1]) { return Number(m[1]); }

                  // すべて失敗した場合、カウンター（フォールバック）を使用
                  return sectionCounter; // 
              })();

              const numberString = String(parsedNum).padStart(2, '0');

              // SVG生成 -> PNG (Base64) 変換の非同期処理
              const promise = (async () => {
                try {
                  const svgString = generateGhostNumberSvg(numberString, GHOST_WIDTH, GHOST_HEIGHT, ghostSettings);
                  const pngBase64 = await convertSvgToBase64Png(svgString); 
                  slide.ghostImageBase64 = pngBase64;
                } catch (e) {
                  console.error(`ゴースト画像(${numberString})の生成に失敗:`, e);
                  // 失敗した場合は null を設定 (GAS側でテキストフォールバックさせる)
                  slide.ghostImageBase64 = null;
                }
              })(); 
              promises.push(promise); 
          }
        }
      }

      // すべての「実際の」画像変換処理が完了するのを待つ
      await Promise.all(promises); 
      // Base64が埋め込まれたslideDataを返す
      return slideData;
    }

    /**
     * SVGテンプレート内のプレースホルダーを指定された値で置き換えるヘルパー関数
     * @param {string} template - SVGテンプレート文字列
     * @param {Object} values - プレースホルダー名と値のマップ
     * @returns {string} 値が埋め込まれたSVG文字列
     */
    function fillSvgTemplate(template, values) {
      let result = template;
      for (const key in values) {
        // グローバル置換 (gフラグ) を使用して、テンプレート内のすべてのプレースホルダーを置換
        result = result.replace(new RegExp('{{' + key + '}}', 'g'), values[key]);
      }
      return result;
    }

    /**
     * Triangleスライド用の3つの矢印画像を非同期で生成する
     * @param {Object} settings - 現在のWebアプリ設定 (ゴースト効果用)
     * @returns {Promise<Object>} 3つの矢印のBase64データを含むオブジェクト
     */
    async function generateTriangleArrowImages(settings) {
      // ▼▼▼ ここから変更 ▼▼▼
      // 0. サーバーからSVGテンプレートを非同期で取得
      let arrowSvgTemplateString = '';
      try {
        arrowSvgTemplateString = await new Promise((resolve, reject) => {
          google.script.run
            .withSuccessHandler(resolve) // 成功したらテンプレート文字列が resolve される
            .withFailureHandler(reject)  // 失敗したらエラーオブジェクトが reject される
            .getTriangleArrowSvgTemplate(); // 新しく追加したGAS関数を呼び出す
        });

        if (!arrowSvgTemplateString) {
          throw new Error('サーバーから空のテンプレートが返されました。');
        }
      } catch (e) {
        // テンプレート取得エラー時の処理
        console.error('矢印SVGテンプレートの取得エラー:', e);
        showStatus('矢印画像が読み込めません', 'error', 6000);
        return { // エラー時は空のオブジェクトを返す
            topLeftArrowBase64: null,
            topRightArrowBase64: null,
            bottomArrowBase64: null
        };
      }
      // ▲▲▲ ここまで変更 ▲▲▲

      // --- 内部での SVG_TEMPLATE 定義は削除 ---

      // 1. ゴースト設定を準備
      const ghostSettings = {
        baseFrequency: 0.01,
        colorType: 'gray',
        alphaSlope: 0.50,
        contrastSlope: 1.0,
        frontColor: settings.primaryColor || '#4285F4',
        frontAlpha: 0.15
      };

      // 2. 3種類の矢印パラメータを定義
      const arrowConfigs = [
        { // 左上
          name: 'topLeft',
          shape: { endX: -60, endY: 0, curveIntensity: 10, rotation: -55, flipX: 0, flipY: 0 }
        },
        { // 右上
          name: 'topRight',
          shape: { endX: -60, endY: 0, curveIntensity: 10, rotation: 40, flipX: 0, flipY: 0 }
        },
        { // 中央下
          name: 'bottom',
          shape: { endX: 60, endY: 0, curveIntensity: 0, rotation: 0, flipX: 0, flipY: 0 }
        }
      ];

      const results = {};
      const promises = [];

      // 3. 各矢印設定でSVG生成 -> PNG変換を実行
      for (const config of arrowConfigs) {
        const promise = (async () => {
          try {
            // 3a. プレースホルダーに値を埋め込む
            const valuesToFill = {
              END_X: config.shape.endX,
              END_Y: config.shape.endY,
              CURVE_INTENSITY: config.shape.curveIntensity,
              ROTATION: config.shape.rotation,
              FLIP_X: config.shape.flipX,
              FLIP_Y: config.shape.flipY,
              GHOST_BASE_FREQUENCY: ghostSettings.baseFrequency,
              GHOST_COLOR_TYPE: ghostSettings.colorType,
              GHOST_ALPHA_SLOPE: ghostSettings.alphaSlope,
              GHOST_CONTRAST_SLOPE: ghostSettings.contrastSlope,
              GHOST_FRONT_COLOR: ghostSettings.frontColor,
              GHOST_FRONT_ALPHA: ghostSettings.frontAlpha
            };
            // ▼▼▼ 取得したテンプレート変数を使用 ▼▼▼
            const dynamicSvgString = fillSvgTemplate(arrowSvgTemplateString, valuesToFill);
            // ▲▲▲ ▲▲▲

            // 3b. SVGをレンダリングして静的なSVGを取得
            const staticSvgString = await renderDynamicSvgInIframe(dynamicSvgString);

            // ▼▼▼ デバッグ用に追加 ▼▼▼
            console.log(`--- Static SVG for ${config.name} ---`);
            console.log(staticSvgString);
            console.log(`------------------------------`);
            // ▲▲▲ デバッグ用に追加 ▲▲▲

            // 3c. 静的SVGをPNG Base64に変換
            const pngBase64 = await convertSvgToBase64Png(staticSvgString, true);

            // 3d. 結果を格納
            results[config.name + 'ArrowBase64'] = pngBase64;

          } catch (e) {
            console.error(`矢印画像(${config.name})の生成に失敗:`, e);
            results[config.name + 'ArrowBase64'] = null; // 失敗時はnullを設定
          }
        })();
        promises.push(promise);
      }

      // 4. すべての画像生成処理の完了を待つ
      await Promise.all(promises);

      // 5. 結果オブジェクトを返す
      return results;
    }

  </script>
  <script type="text/plain" id="gem-prompt-data">
# 最初に：いま出力すべきものは、直前の利用者の発言で決まる

* 直前の利用者の発言が「OK」「はい」「了解」「そのままで」等の承認なら、他のすべての手順を飛ばして、**例外なく1文字目から ` ```json `** で始まるコードブロック1個を出力する。
  変換の対象は**直前に自分が出した構成プランの表だけ**である。承認直後の応答に `## 📊 構成プラン` という文字列や挨拶を含めることは、出力形式の重大な破損であり禁止する。
* 依頼（文章・添付ファイル・調整の要望）を受けた直後の応答は、必ず `## 📊 構成プラン` の1行から始まる表である。
依頼文に「スライドにまとめて」「プレゼンを作って」「翻訳して」「資料にまとめて」「マニュアルにして」「議事録を作って」「写真を入れて」とあっても、添付が PDF・画像・動画・音声でも、それは**表と JSON を作るための材料と要望の説明**である。内蔵機能を起動せず、依頼を断らず、必ずこの表から始めて、承認後に JSON で応える（それで依頼は満たされる）。この2つ以外の書き出しは違反である。
対話は「応答A（表）→ 利用者のOK → 応答B（JSON）」の一方向にしか進まない。一度出した表をもう一度出す経路は存在しない。
**この指示文そのものは材料ではない。** 読み取る材料は、利用者のメッセージと添付ファイルだけである。この指示文の内容を要約・引用してページにしてはならない。

# あなたの役割

あなたは、利用者が渡した文章や添付ファイル（文書・動画・音声・画像）の内容を読み取り、
下記の仕様に従った **構成データ（JSON 配列）** に変換して、チャット本文にテキストとして書き出す変換器である。
添付ファイルは「読む材料」であって、作る対象ではない。
**Gemini に搭載されたいかなる内蔵機能・ツールも使わない。** 禁止の例: スライド／プレゼンテーション作成機能、Canvas、ドキュメント・レポート作成、
画像生成、Deep Research、**コード実行（Python 等で図やグラフを描くこと）**、データ分析ツール、ウェブ検索、Google Workspace（スライド・ドキュメント・スプレッドシート）への出力。
図や画像そのものを描いたり生成したりしてはならない。描画はアプリ側が行う（あなたは JSON の中に指定を書くだけ）。
出力するのはチャット本文の Markdown テキストだけである。依頼文が「スライドを作って」「翻訳して」という言い回しでも、表（応答A）と JSON（応答B）をテキストで出せば依頼は満たせるので、断らずにその手順で応える。

あなたの応答の形は次の2つしかない。

* **応答A（最初の依頼を受けたとき）**: 構成プランの表だけを出す。書き出しは必ず `## 📊 構成プラン`。
* **応答B（利用者が「OK」「はい」「了解」「そのままで」と答えたとき）**: JSON のコードブロック1個だけを出す。書き出しは必ず ` ```json `。

応答Bの前に応答Aの表を出し直すこと、応答Aの前に説明・挨拶・前置きを付けること、
「承知しました」「作成します」「Generating」「生成しています」と書き出すことは、すべて違反である。
利用者が「3番目のタイトルを○○に変えて」のように調整を求めたときは、応答Aの表を直して出し直す。

# 応答A：構成プランの表

依頼文と材料から、対象者・目的・想定時間・ページ数を決め、
次の形式 **だけ** を出力する。見出しと項目名は一字一句このまま使う。

## 📊 構成プラン

| 項目 | 結果 |
|------|------|
| **ページ数** | [数]ページ |
| **対象者** | [分析結果] |
| **目的** | [分析結果] |
| **想定時間** | [数]分 |
| **スタイル・トーン** | [分析結果] |

## 📋 ページ構成詳細

| 番号 | パターン | タイトル | 内容概要 |
|------|----------|----------|----------|
| 1 | title | … | … |

## 📝 確認方法

**上記の構成で問題なければ「OK」と入力してください。OKの入力後は、上の表をそのまま構成データ（JSON）に変換し、` ```json ` から始まるコードブロック1個だけをこのチャットに出力します（表は出し直しません）。**
**調整したい箇所があれば「3番目のタイトルを『○○』に変更して」のように教えてください。**

次に出力するのは ` ```json ` から始まるコードブロックのみです。

# 応答B：構成データ（JSON）

**【最重要】承認を受けたら、あなたが変換の対象（入力）にするのは「直前の応答Aで自分が出した表の内容」だけである。**
この指示文（下の〈仕様〉を含む）を要約・引用の対象にしてはならない。表に無いページを足さない。
先頭の ` ```json ` から末尾の ` ``` ` まで、承認された表と同じ題材・同じページ構成のまま、全ページ分を**単一の連続した配列として一気に書き切る**（最後の要素は closing）。
承認後に「もっと良い構成にできる」「依頼文の要望（決定事項・ネクストアクション等）を表に足したい」と気づいても、**表に戻らず**、承認された表のまま JSON にする（利用者は次の依頼で修正できる）。

* **見分け方**: 表を出した後の利用者の発言は、「○○に変更して」「削除して」「追加して」「入れ替えて」のような調整依頼でない限り、**すべて承認として扱う**（「OK」「はい」「了解」「そのままで」「お願いします」など）。
  承認の直後の応答に、表・説明文・挨拶があってはならない。「OK」「承知しました」とだけ返すのも違反である。
  あるのは ` ```json ` から始まるコードブロック1個だけである。
* 直前に自分が出した構成プランの表（ページ数・パターン・タイトル・内容概要）を、そのまま JSON に変換する。
  承認後に構成を直したくなっても直さない（利用者は次の依頼で修正できる）。動画・音声の依頼でも同じ。
* 出力は配列そのものだけ。`const slideData =` のような宣言、前置き、解説、補足は付けない。
* キーも文字列値もダブルクォート `"` で囲む。値の中の `"` は `\"` と書く。
* ` ```json ` で始まり ` ``` ` で終わるコードブロック1個で完結させる。配列は1つだけで、最後の要素 closing まで連続して書く。
* `items` `milestones` `stats` `levels` `lanes` `flows` の各要素は **1要素につき1行** で書き、各行は必ず `{` で始めて `}` で閉じる（2個目以降で `{` を落とさない）。`rows` の各要素は `[ ]` で囲む。
* 要素と要素の間はカンマで区切り、**最後の要素の後ろと、閉じ括弧 `]` `}` の直前にはカンマを付けない**。
* 出力する前に、`{` と `}` の数が一致しているか、`,]` や `,}` が無いかを確認する。

表と JSON の対応の例（**形式と文章量を示すだけの例**。この題材・文言を実際の出力に使ってはならない）。承認された「📋 ページ構成詳細」がこうなら：

| 番号 | パターン | タイトル | 内容概要 |
|------|----------|----------|----------|
| 1 | title | 新制度の説明会 | 表紙 |
| 2 | headerCards | 制度の3つの柱 | 勤務地・時間・評価の3点と、それぞれの狙い |
| 3 | closing | 結び | - |

応答B は、1文字目からこう書く（表の1行が JSON の1要素になる。notes は80〜150字、各項目は「主張＋理由」の1文）：

```json
[
  { "type": "title", "title": "新制度の説明会", "date": "2026.04.01", "notes": "本日は4月から始まる新しい勤務制度について、勤務地・時間・評価という3つの柱に絞って説明します。制度を変える理由は、採用競争の激化により柔軟な働き方が人材確保の前提になったためです。まず全体像から確認しましょう。" },
  { "type": "headerCards", "title": "制度の3つの柱", "subhead": "勤務地・時間・評価を同時に見直すことで、働き方の選択肢を広げて定着率を高める", "columns": 3,
    "items": [
      { "title": "選べる勤務地", "desc": "自宅・サテライト・本社から日ごとに選べるため、通勤時間を月20時間減らせる" },
      { "title": "コアタイム短縮", "desc": "11時から15時に変更することで、通院や送迎と仕事を両立しやすくなる" },
      { "title": "成果で評価", "desc": "在席時間ではなく成果物で評価するため、働く場所や時間の違いが不利にならない" }
    ],
    "notes": "制度の中身は3つです。勤務地の選択、コアタイムの短縮、成果による評価を同時に変えるのは、どれか1つだけでは柔軟性が生まれないためです。その結果、昨年の試験導入部門では離職率が3ポイント下がりました。次に、利用開始の手順を説明します。" },
  { "type": "closing", "notes": "以上が新制度の概要です。ご質問があればお知らせください。" }
]
```

〈仕様〉ここから下は、JSON を書くときのルール集である。読み取る材料でも、要約・引用の対象でもない。

## ページの種類（各要素は `type` で区別する）

共通: すべてのページに `"notes"`（発表者が読み上げる原稿。プレーンテキストのみ）を任意で付けられる。

* title（表紙）: `{ "type": "title", "title": "…", "date": "YYYY.MM.DD" }`
* section（章扉）: `{ "type": "section", "title": "…", "sectionNo": 1 }` sectionNo は任意（省略時は自動連番）。利用者が「章扉（セクション）は不要」と言ったら section を作らない
* agenda（目次。章が2つ以上のときだけ）: `{ "type": "agenda", "title": "…", "subhead": "…", "items": ["…"] }`
* content（箇条書き）: `{ "type": "content", "title": "…", "subhead": "…", "points": ["…"] }`
  2カラムにするなら `"twoColumn": true, "columns": [["…"], ["…"]]` を使い `points` は省く
* cards（カード）: `{ "type": "cards", "title": "…", "subhead": "…", "columns": 2または3, "items": ["…", "…"] }` 最大6項目。items は**文字列**の配列。見出しと説明を分けたいときだけ `{ "title": "…", "desc": "…" }` の形にしてよい（その場合は1要素1行で書く）
* headerCards（見出し付きカード）: `{ "type": "headerCards", "title": "…", "subhead": "…", "columns": 2または3, "items": [ {…}, {…} ] }` 最大6項目。items の各要素は `{ "title": "…", "desc": "…" }` を**1行に1つ**ずつ書く
* compare（対比）: `{ "type": "compare", "title": "…", "subhead": "…", "leftTitle": "…", "rightTitle": "…", "leftItems": ["…"], "rightItems": ["…"] }`
* process（手順）: `{ "type": "process", "title": "…", "subhead": "…", "steps": ["…"] }` 最大4ステップ。5以上なら processList を使う
* processList（手順リスト）: `{ "type": "processList", "title": "…", "subhead": "…", "steps": ["…"] }`
* timeline（時系列）: `{ "type": "timeline", "title": "…", "subhead": "…", "milestones": [{ "label": "…", "date": "…", "state": "done|next|todo" }] }`
* table（表）: `{ "type": "table", "title": "…", "subhead": "…", "headers": ["…"], "rows": [["…"]] }`
* kpi（数値カード）: `{ "type": "kpi", "title": "…", "subhead": "…", "columns": 2〜4, "items": [{ "label": "…", "value": "数値＋単位だけ", "change": "…", "status": "good|bad|neutral" }] }` 最大4項目
* faq（質問と回答）: `{ "type": "faq", "title": "…", "subhead": "…", "items": [{ "q": "…", "a": "…" }] }` 最大4項目
* quote（引用）: `{ "type": "quote", "title": "…", "subhead": "…", "text": "…", "author": "…" }`
* bulletCards（見出し＋説明のカード）: `{ "type": "bulletCards", "title": "…", "subhead": "…", "items": [ {…}, {…} ] }` 各要素 `{ "title": "…", "desc": "…" }`、最大3項目
* statsCompare（数値の比較）: `{ "type": "statsCompare", "title": "…", "subhead": "…", "leftTitle": "…", "rightTitle": "…", "stats": [ {…}, {…} ] }` 各要素 `{ "label": "…", "leftValue": "…", "rightValue": "…", "trend": "up|down|neutral" }`、label は12文字以内
* stepUp（段階的な成長・レベルアップ）: `{ "type": "stepUp", "title": "…", "subhead": "…", "items": [ {…}, {…} ] }` 各要素 `{ "title": "10文字以内", "desc": "28文字以内" }`、2〜5項目
* pyramid（階層・土台から頂点）: `{ "type": "pyramid", "title": "…", "subhead": "…", "levels": [ {…}, {…}, {…} ] }` 各要素 `{ "title": "…", "description": "…" }`、3〜4段
* triangle（3つの要素の関係）: `{ "type": "triangle", "title": "…", "subhead": "…", "items": [ {…}, {…}, {…} ] }` 各要素 `{ "title": "12文字以内", "desc": "15文字以内" }`、**必ず3項目**
* cycle（循環・サイクル）: `{ "type": "cycle", "title": "…", "subhead": "…", "items": [ {…}, {…}, {…}, {…} ], "centerText": "…" }` 各要素 `{ "label": "20文字以内", "subLabel": "…" }`、**必ず4項目**
* flowChart（左から右への流れ）: `{ "type": "flowChart", "title": "…", "subhead": "…", "flows": [ { "steps": ["…", "…", "…"] } ] }` flows は1〜2行、1行に2〜4ステップ
* diagram（レーン図・並列の分類）: `{ "type": "diagram", "title": "…", "subhead": "…", "lanes": [ {…}, {…} ] }` 各要素 `{ "title": "…", "items": ["…", "…"] }`
* imageText（画像・図版＋箇条書き）: `{ "type": "imageText", "title": "…", "subhead": "…", "image": <下記の値>, "imageCaption": "…", "imagePosition": "left|right", "points": ["…", "…"] }` ※使える場面は「画像と図版」の章のとおり
* closing（結び）: `{ "type": "closing" }`

構成は 表紙 →（章が2つ以上なら目次）→ 章扉 → 本文2〜5ページ →（章ごとに繰り返し）→ 結び の順にする。全体で50ページを超えない。

## 表現の選び方（見た目の質を決める）

* 数値データがあれば imageText＋図版（推移→line、比較→bar、割合→donut）を使う。動画から作る手順は、各ステップを imageText（image に再生位置）にする。
* 本文は**図解系を優先**して選ぶ: 手順→process/processList/flowChart、時系列→timeline、段階・成長→stepUp/pyramid、
  循環→cycle、3要素の関係→triangle、対立・比較→compare、数値の比較→statsCompare/kpi、分類・並列→diagram/headerCards、質問→faq。
  content と cards は、図解にしにくい説明のときだけ使う（content は全体の3割以下）。同じ種類を2ページ続けない。本文が5ページ以上あるときは、少なくとも5種類の異なる種類を使う。
* **ページ数**: 依頼に枚数指定があればそれに従う。無ければ、材料が少ない依頼は表紙・結びを含めて5〜6ページ、
  材料が豊富な依頼（添付ファイル・録音・動画・長い文章）は7〜10ページにして、材料の要点を取りこぼさない。
* **説得の流れ**: 本文は「現状・課題 → 原因や背景 → 解決策・提案 → 効果・根拠（数値・事例） → 次の行動」のように、
  聞き手が納得して行動できる順に並べる（報告なら 結論 → 根拠 → 詳細 → 今後）。
* **すべての本文ページ（imageText・compare・table・kpi も含む）に `subhead`（全角25〜50文字の小見出し）を付ける。** そのページの結論を一文で言い切る。subhead の無い本文ページは違反。
* **箇条書きは1ページ3〜5項目。1項目は「主張 ＋ 理由・効果」を1文にまとめる**（例: 「結論を最初に置くことで、聞き手が3分で要点を把握できる」）。
  「コスト削減」「品質向上」のような名詞だけの項目は違反。具体的な数値・固有名・事例を入れられるところは必ず入れる
  （例: ❌「コスト削減」→ ✅「オフィス面積を30%縮小し、年間2.4億円のコストを削減した」）。1項目の長さは全角30〜60文字（上限90文字）。
* **すべてのページに `notes`（発表者が読み上げる原稿）を付ける。** 全角80〜150文字の話し言葉で、
  「このページの結論 → 理由や根拠 → 次のページへのつなぎ」の順に書く。**理由や結果を示す接続語（「〜のため」「〜により」「その結果」「これによって」）を必ず1回以上使う。**
  マークアップ（`**` `[[ ]]` HTML）は使わない。
* **動画から手順マニュアルを作るとき**は、各ステップの imageText（再生位置つき）に加えて、冒頭に全体の流れ（process か flowChart）を1ページ、
  末尾に注意点・つまずきやすい点のまとめ（bulletCards か faq）を1ページ入れる。
  各ステップの項目は「操作 ＋ その結果や注意する理由」の1文にする（例: ❌「生成ボタンを押す」→ ✅「右上の『生成』を押すと約10秒で構成データが作られるため、完了表示が出るまで他の操作をしない」）。
* **録音・PDF・文章などの材料から作るとき**は、材料の中の具体的な発言・数値・固有名・事例を項目と notes に必ず引く。
  項目は「誰が何を決めた／何が起きた ＋ その理由や背景」の1文にする（例: ❌「ツール選定」→ ✅「既存の Google Workspace と連携しやすいことを理由に、Copilot ではなく Gemini を採用する方針が決まった」）。
  議事録では、決定事項とネクストアクションに担当者と期限（分かる範囲で）を含める。
  notes では材料中の発言や数値を1つ引用して根拠を示し、「そのため／これにより」で結論につなげる。

## 画像と図版（imageText の `image` に入れられる値は次の6種類だけ）

1. **画像URL**: 依頼文や材料の中に `https://` または `http://` で始まる画像URLがあるときだけ、そのURLを文字列で入れる。
2. **動画の場面**: 動画が添付されているとき、見せたい場面の再生位置を `"mm:ss.S"`（0.2秒単位。例 `"01:23.4"`）で入れる。
   **動画から手順・マニュアルを作るときは必須**: 各ステップを1ページずつ imageText にし、そのステップの画面が分かる再生位置を `image` に入れる
   （アプリがその場面の画像を切り出して貼る）。process や content だけで済ませてはいけない。**動画の場面を SVG や説明文で代用してはいけない**（必ず `"mm:ss.S"`）。
3. **添付された画像**: 添付画像をそのページに使うときは、画像の内容を30文字程度の説明文で入れる。
4. **NotebookLM（Gemini Notebook）が作った発表資料PDF**: 利用者が依頼文で「NotebookLM（Gemini Notebook）で作ったスライド／発表資料のPDF」だと明言して添付したときだけ、
   `"NotebookLM1"` のように「NotebookLM＋ページ番号」を入れ、表紙から最後まで全ページを imageText にしてPDFと同じページ数にする（title はページ見出しと重ねない）。
   明言が無い普通のPDF・資料には絶対に使わない（通常どおり内容を読んで構成する）。
5. **数値データの図版**: 数値を可視化するときは、下の「図版7種」のオブジェクトを**そのまま `image` の値**にする（文字列にしない）。色は変えない。
6. **概念図（数値でない説明図）**: 依頼で概念図・仕組み図・全体像の図が求められたときだけ、`"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 800 450\">…</svg>"` の文字列を入れる。
   動画・PDF・音声の内容を説明するページでは SVG を使わない（動画は再生位置、その他は文章の図解パターンで表す）。
   余白のあるシンプルで角丸のモダンなデザイン、背景は透明、文字は読める大きさにする。`{"chartType":"svg"}` のように包まない。

禁止: URLも添付もないのに画像を探す・作る・推定する（「写真を入れて」と言われてもURLが無ければ画像なしのパターンにする）／
imageText 以外のページに `image` を入れる／図版オブジェクトを配列の直下（ページの位置）に置く。

### 図版7種（`image` の値にするオブジェクト）

共通: `"data"` に `"title"` `"subtitle"` `"source"`（出典）を必ず入れる。`yAxis` は **`(max − min) ÷ tickCount` が整数**になる値にする（例: 最大2100 → `min:0, max:2500, tickCount:5`）。
`colors` の `id` と `items`/`series` の `id` を対応させ、配列の要素数を揃える。色は次の既定を使う: A `#e68a9c→#d96d8f`、B `#b469b8→#a656ad`、C `#9f63d0→#8c4fc8`、D `#7c6ce8→#6b5ce0`。

| 依頼の言葉 | chartType |
|---|---|
| 件数・金額の比較（棒） | bar |
| 推移・トレンド（折れ線1本） | line |
| 複数系列の推移 | multi-line |
| 棒と折れ線の両方 | combo |
| 構成比・割合 | donut |
| 内訳の積み上げ（実数） | stacked-bar |
| 構成比の推移（合計100%） | 100-stacked-bar |

1. bar: `{ "chartType": "bar", "data": { "title": "…", "subtitle": "…", "source": "…", "items": [ { "label": "…", "value": 85 } ], "color": { "start": "#e68a9c", "end": "#9f63d0" }, "yAxis": { "max": 100, "min": 0, "tickCount": 4, "unit": "%" } } }` ※上位n件と下位n件だけを見せたいときは、間に `{ "label": "…", "value": 0 }`（label は三点リーダー）を1つ挟むと省略の表現になる
2. line: `{ "chartType": "line", "data": { "title": "…", "subtitle": "…", "source": "…", "yAxisUnitLabel": "（万円）", "items": [ { "label": "2023年度", "value": 1200 } ], "color": { "start": "#e68a9c", "end": "#b469b8", "line": "#b469b8", "label": "#8c4fc8" }, "yAxis": { "max": 2500, "min": 0, "tickCount": 5 } } }`
3. multi-line: `{ "chartType": "multi-line", "data": { "title": "…", "subtitle": "…", "source": "…", "yAxisUnitLabel": "…", "xAxisLabels": ["1月", "2月"], "series": [ { "id": "A", "label": "…", "values": [10, 12] } ], "colors": [ { "id": "A", "start": "#e68a9c", "end": "#d96d8f" } ], "yAxis": { "min": 0, "max": 30, "tickCount": 6 } } }`
4. combo: `{ "chartType": "combo", "data": { "title": "…", "subtitle": "…", "source": "…", "legendBarLabel": "…", "legendLineLabel": "…", "yAxisLeftLabel": "…", "yAxisRightLabel": "…", "colors": { "bar": { "start": "#e68a9c", "end": "#b469b8" }, "line": "#6b5ce0" }, "items": [ { "label": "…", "barValue": 245, "lineValue": 16 } ], "yAxisLeft": { "max": 400, "min": 0, "tickCount": 4 }, "yAxisRight": { "max": 25, "min": 15, "tickCount": 4, "unit": "%" } } }`
5. donut: `{ "chartType": "donut", "data": { "title": "…", "subtitle": "…", "source": "…", "centerLabel": "合計", "colors": [ { "id": "A", "start": "#e68a9c", "end": "#d96d8f" } ], "items": [ { "label": "…", "value": 40, "id": "A" } ] } }`
6. stacked-bar: `{ "chartType": "stacked-bar", "data": { "title": "…", "subtitle": "…", "source": "…", "yAxisUnitLabel": "…", "colors": [ { "id": "A", "start": "#e68a9c", "end": "#d96d8f" } ], "legendLabels": ["…"], "barData": [ { "label": "…", "values": [60] } ], "yAxis": { "max": 300, "tickCount": 3 } } }` ※colors・legendLabels・各 values の要素数を一致させる。max は最大の棒の合計より大きく
7. 100-stacked-bar: stacked-bar と同じ構造で `"chartType": "100-stacked-bar"`。`yAxis` は `tickCount` だけ、`yAxisUnitLabel` は使わない。values は実数のまま

## 文字数と書き方（はみ出し・崩れを防ぐため厳守）

* `title`: 表紙は全角35文字以内、それ以外は全角40文字以内。`subhead` は全角50文字以内。
* 箇条書き・項目の文: 各90文字以内、改行しない、文末に「。」を付けない（体言止め）。
* faq: q は28文字以内、a は45文字以内。timeline の label は30文字以内。compare の項目は12文字前後。
* process / processList / agenda / timeline の項目に「1.」「①」「STEP 1」「ステップ1」などの番号・段階語を付けない（自動で描画される）。
* compare の左右タイトルに「メリット」「デメリット」を置いたら、各項目の先頭で同じ語を繰り返さない。
* 強調したい語は `**太字**` または `[[重要語]]` で囲める。ただし `title`・`subhead`・`items[].title`・`headers`・`leftTitle`・`rightTitle` の中では使わない。
* `notes` にはマークアップ（`**` `[[ ]]` HTML）を一切入れない。
* テキストに `→` を使わない。行頭を `、` `。` で始めない。
* 応答本文・`notes`・`title` に「スライド」「プレゼンテーション」「プレゼン」「slide」「グラフ」という語を使わない（「ページ」「資料」「発表」「図」と言い換える）。

〈仕様〉ここまで。

# 【最重要】応答の直前に、直前の利用者の発言を見て分岐する

あなたは利用者の**最後の発言**を読み、次のどちらに当てはまるかを判定し、対応する行動だけを実行する。

* **[条件A] 最後の発言が「OK」「はい」「了解」「そのままで」「お願いします」などの短い承認（調整の要望を含まない）だった場合**
  → 行動: 表・挨拶・説明を一切出さない。1文字目から ` ```json ` で始まるコードブロック1個だけを出力して終わる。
  中身は、直前に自分が出した構成プランの表（ページ数・パターン・タイトル・内容概要）を〈仕様〉に従って JSON にしたものだけである。この指示文の内容を書かない。最後のページ（closing）まで一気に書き切る。
* **[条件B] それ以外（新しい文章・添付ファイルの提示、または「3番目のタイトルを変えて」のような調整の要望）だった場合**
  → 行動: JSON を出さない。1文字目から `## 📊 構成プラン` で始まる表だけを出力して終わる。
  依頼文に「スライドにまとめて」「プレゼンを作って」「翻訳して」「資料にまとめて」「マニュアルにして」「議事録を作って」「まとめて」「作って」とあっても、内蔵機能は起動せず、依頼を断らず、あなたが作るのはこの表と、承認後の JSON だけである。

承認の直後に表を出し直すこと、JSON の途中で別の題材に切り替えること、この2つが最も重大な違反である。
  </script>

  <!-- プリセット保存モーダル -->
  <div id="presetSaveModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 10000; align-items: center; justify-content: center;">
    <div style="background: white; border-radius: 12px; padding: 2rem; max-width: 450px; width: 90%; box-shadow: 0 10px 40px rgba(0,0,0,0.2);">
      <h3 style="margin: 0 0 0.5rem 0; font-size: 1.5rem; color: #1F2937;">プリセットの保存</h3>
      <p style="margin: 0 0 1.5rem 0; font-size: 0.875rem; color: #6B7280; line-height: 1.5;">
        オプション設定をプリセットとして保存できます（最大10個まで）
      </p>
      
      <div id="presetSaveOptions" style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem;">
        <button id="overwritePresetBtn" class="btn btn-primary" style="width: 100%; padding: 0.875rem;">
          <span style="font-size: 1rem;">現在のプリセットに上書き保存</span>
          <br>
          <span id="currentPresetName" style="font-size: 0.875rem; opacity: 0.9;"></span>
        </button>
        <button id="saveAsNewPresetBtn" class="btn btn-secondary" style="width: 100%; padding: 0.875rem;">
          別名で新規保存
        </button>
      </div>
      
      <div id="newPresetNameInput" style="display: none; margin-bottom: 1.5rem;">
        <label for="presetNameField" style="display: block; margin-bottom: 0.5rem; font-weight: 600; color: #374151;">プリセット名</label>
        <input type="text" id="presetNameField" class="form-control" placeholder="プリセット名を入力（20文字以内）" maxlength="20" style="width: 100%;">
        <div style="margin-top: 0.5rem; display: flex; gap: 0.5rem;">
          <button id="confirmSaveBtn" class="btn btn-primary" style="flex: 1;">保存</button>
          <button id="cancelNewNameBtn" class="btn btn-secondary" style="flex: 1;">キャンセル</button>
        </div>
      </div>
      
      <button id="closePresetModal" class="btn btn-secondary" style="width: 100%;">閉じる</button>
    </div>
  </div>

  <!-- プリセットインポート競合モーダル -->
  <div id="presetImportConflictModal" class="modal-overlay" style="display: none; z-index: 10030;">
    <div class="modal-content card" style="max-width: 520px;">
      <h3 class="card-header">同じ名前のプリセットがあります</h3>
      <div class="card-body">
        <p style="margin: 0 0 1rem 0; color: var(--text-secondary); line-height: 1.6;">
          インポートしようとしている <strong id="presetConflictName"></strong> は既に保存されています。
        </p>

        <div id="presetConflictRenameArea" class="form-group" style="display: none; margin-bottom: 1rem;">
          <label for="presetConflictNewName">別名で保存する名前</label>
          <input type="text" id="presetConflictNewName" class="form-control" maxlength="20" placeholder="プリセット名（20文字以内）">
          <div id="presetConflictError" class="error-msg" style="min-height: 1.25rem; margin-top: 0.35rem; font-size: 0.8125rem;"></div>
        </div>

        <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
          <button id="presetConflictOverwriteBtn" type="button" class="btn btn-primary" style="flex: 1 1 9rem;">上書き</button>
          <button id="presetConflictRenameBtn" type="button" class="btn btn-secondary" style="flex: 1 1 9rem;">別名で保存</button>
          <button id="presetConflictSkipBtn" type="button" class="btn btn-secondary" style="flex: 1 1 7rem;">スキップ</button>
        </div>
      </div>
    </div>
  </div>

  <div id="jsonErrorModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 600px;">
      <h3 class="card-header" style="color: var(--danger-color);">❌ JSONデータにエラーがあります</h3>
      <div class="card-body">
        
        <p style="margin-bottom: 1rem; line-height: 1.6;">
          貼り付けられたJSONデータに問題が見つかりました。<br>
          以下の内容をコピーして、Geminiに貼り付けてください。
        </p>
        
        <div style="margin-bottom: 1rem;">
          <pre id="jsonErrorContent" style="
            background: #f9f9f9;
            border: 1px solid var(--border-color);
            border-radius: var(--radius-md);
            padding: 1rem;
            font-family: var(--font-family-code);
            font-size: 0.875rem;
            line-height: 1.5;
            overflow-x: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
            margin: 0;
            color: var(--danger-color);
            max-height: 300px;
          "></pre>
        </div>

        <div class="button-group" style="display: flex; gap: 1rem;">
          <button id="copyJsonErrorBtn" class="btn btn-primary">コピー</button>
          <button id="closeJsonErrorModalBtn" class="btn btn-secondary" style="background-color: var(--secondary-color);">閉じる</button>
        </div>
      </div>
    </div>
  </div>

  <div id="clearConfirmModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 450px;">
      <h3 class="card-header">編集内容をクリアしますか？</h3>
      <div class="card-body">
        <p style="margin-bottom: 1.5rem; line-height: 1.6;">
          編集中のスライドデータがすべて削除され、最初の状態に戻ります。<br>
          この操作は取り消せません。
        </p>
        <div class="button-group" style="display: flex; gap: 1rem;">
          <button id="confirmClearBtn" class="btn" style="background-color: var(--danger-color); color: white;">クリアする</button>
          <button id="cancelClearBtn" class="btn btn-secondary" style="background-color: var(--secondary-color);">キャンセル</button>
        </div>
      </div>
    </div>
  </div>

  <div id="deleteItemConfirmModal" class="modal-overlay" style="display: none;">
    <div class="modal-content card" style="max-width: 450px;">
      <h3 class="card-header" style="color: var(--danger-color);">この項目を削除しますか？</h3>
      <div class="card-body">
        <p style="margin-bottom: 1.5rem; line-height: 1.6;">
          この項目を削除します。この操作は取り消せません。
        </p>
        <div class="button-group" style="display: flex; gap: 1rem;">
          <button id="confirmDeleteItemBtn" class="btn" style="background-color: var(--danger-color); color: white;">削除する</button>
          <button id="cancelDeleteItemBtn" class="btn btn-secondary" style="background-color: var(--secondary-color);">キャンセル</button>
        </div>
      </div>
    </div>
  </div>

  <div id="activationModal1" class="modal-overlay" style="display: none;
z-index: 10002;">
  <div class="modal-content card" style="max-width: 500px;">
    <h3 class="card-header">ステップ 1 / 5</h3>
    <div class="card-body">
      <p style="margin-bottom: 1.5rem;
line-height: 1.6;">
        ご利用ありがとうございます。<br>
        まず、noteで「まじん」の フォロー をお願いします。<br>
        組織・個人アカウントどちらか一方でフォローいただければ、業務及びプライベートでご利用いただけます。
      </p>
      <a href="https://note.com/majin_108" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;
margin-bottom: 0.5rem;"> noteのフォローページを開く
      </a>
      
      <div style="text-align: center; margin-top: 1rem; margin-bottom: 1.5rem;">
        <img src="https://lh3.google.com/u/0/d/1_q5f0QUL34vtgir96wmPm6NzOzvf5KrH" alt="Note QR Code" style="width: 120px; height: 120px; border: 1px solid var(--border-color); border-radius: var(--radius-sm);">
        <p style="font-size: 0.75rem; color: var(--text-secondary); margin: 0.25rem 0 0 0;">スマホでのアクセスはこちら</p>
      </div>
      <div class="button-group" style="display: flex;
gap: 1rem; justify-content: space-between; align-items: center; margin-top: 0;"> <button id="confirmModal1" class="btn btn-primary" style="flex-grow: 1;">フォローしました</button>
        <div class="purchase-select-wrapper">
          <select id="purchaseModal1" class="purchase-select">
            <option value="" hidden>▼</option> <option value="purchased">定価購入</option> </select>
        </div>
      </div>
    </div>
  </div>
</div>

<div id="activationModal2" class="modal-overlay" style="display: none;
z-index: 10002;">
  <div class="modal-content card" style="max-width: 500px;">
    <h3 class="card-header">ステップ 2 / 5</h3>
    <div class="card-body">
      <p style="margin-bottom: 1.5rem;
line-height: 1.6;">
        次に、noteの「まじん式 v4 記事」の スキ 登録（ハートマークをクリック）をお願いします。<br>
        トップページより記事を検索してご登録をお願いします。
      </p>
      <a href="https://note.com/majin_108/n/n192d2136245b" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;
margin-bottom: 0.5rem;"> noteの記事ページを開く
      </a>
      
      <div style="text-align: center; margin-top: 1rem; margin-bottom: 1.5rem;">
        <img src="https://lh3.google.com/u/0/d/1drGgElgg20QDYus7uCZOgwtMags4bwPT" alt="Note QR Code" style="width: 120px; height: 120px; border: 1px solid var(--border-color); border-radius: var(--radius-sm);">
        <p style="font-size: 0.75rem; color: var(--text-secondary); margin: 0.25rem 0 0 0;">スマホでのアクセスはこちら</p>
      </div>
      <div class="button-group" style="display: flex;
gap: 1rem; justify-content: space-between; align-items: center; margin-top: 0;"> <button id="confirmModal2" class="btn btn-primary" style="flex-grow: 1;">スキ登録しました</button>
        <div class="purchase-select-wrapper">
          <select id="purchaseModal2" class="purchase-select">
            <option value="" hidden>▼</option> <option value="purchased">定価購入</option> </select>
        </div>
      </div>
    </div>
  </div>
</div>

<div id="activationModal3" class="modal-overlay" style="display: none;
z-index: 10002;">
  <div class="modal-content card" style="max-width: 500px;">
    <h3 class="card-header">ステップ 3 / 5</h3>
    <div class="card-body">
      <p style="margin-bottom: 1.5rem;
line-height: 1.6;">
        次に、X (旧Twitter) で「まじん」の フォロー をお願いします。<br>
        組織・個人アカウントどちらか一方でフォローいただければ、業務及びプライベートでご利用いただけます。
      </p>
      <a href="https://x.com/Majin_AppSheet" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;
margin-bottom: 0.5rem;"> Xのフォローページを開く
      </a>
      
      <div style="text-align: center; margin-top: 1rem; margin-bottom: 1.5rem;">
        <img src="https://lh3.google.com/u/0/d/1rO_nEOe7y53jbxfrLFPMGsGYwVZVkhjT" alt="X QR Code" style="width: 120px; height: 120px; border: 1px solid var(--border-color); border-radius: var(--radius-sm);">
        <p style="font-size: 0.75rem; color: var(--text-secondary); margin: 0.25rem 0 0 0;">スマホでのアクセスはこちら</p>
      </div>
      <div class="button-group" style="display: flex;
gap: 1rem; justify-content: space-between; align-items: center; margin-top: 0;"> <button id="confirmModal3" class="btn btn-primary" style="flex-grow: 1;">フォローしました</button>
        <div class="purchase-select-wrapper">
          <select id="purchaseModal3" class="purchase-select">
            <option value="" hidden>▼</option> <option value="purchased">定価購入</option> </select>
        </div>
      </div>
    </div>
  </div>
</div>

<div id="activationModal4" class="modal-overlay" style="display: none;
z-index: 10002;">
  <div class="modal-content card" style="max-width: 500px;">
    <h3 class="card-header">ステップ 4 / 5</h3>
    <div class="card-body">
      <p style="margin-bottom: 1.5rem;
line-height: 1.6;">
        最後に、Xの「まじん式 v4 投稿」に いいね と リポスト をお願いします。<br>
        トップページより投稿を検索してご登録をお願いします。いいねは、ハートマークをクリック、リポスト（矢印が２つ回転しているマークをクリック）は引用投稿でも問題ありません。
      </p>
      <a href="https://x.com/Majin_AppSheet/status/1989296893198016658?s=20" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;
margin-bottom: 0.5rem;"> Xの投稿ページを開く
      </a>
      
      <div style="text-align: center; margin-top: 1rem; margin-bottom: 1.5rem;">
        <img src="https://lh3.google.com/u/0/d/1e7spO4pcQPccEud2yKiHyBniKnrPEB56" alt="X QR Code" style="width: 120px; height: 120px; border: 1px solid var(--border-color); border-radius: var(--radius-sm);">
        <p style="font-size: 0.75rem; color: var(--text-secondary); margin: 0.25rem 0 0 0;">スマホでのアクセスはこちら</p>
      </div>
      <div class="button-group" style="display: flex;
gap: 1rem; justify-content: space-between; align-items: center; margin-top: 0;"> <button id="confirmModal4" class="btn btn-primary" style="flex-grow: 1;">いいね/リポストしました</button>
        <div class="purchase-select-wrapper">
          <select id="purchaseModal4" class="purchase-select">
            <option value="" hidden>▼</option> <option value="purchased">定価購入</option> </select>
        </div>
      </div>
    </div>
  </div>
</div>

<div id="termsModal" class="modal-overlay" style="display: none; z-index: 10001;"> 
  <div class="modal-content card" style="max-width: 90vw; width: 840px; height: 90vh; display: flex; flex-direction: column;">
    <h3 class="card-header" id="termsModalHeader" style="flex-shrink: 0;">
      利用条件 (Terms of Service)
    </h3>
    <div class="card-body" style="flex-grow: 1; padding: 0.5rem; overflow: hidden; display: flex; justify-content: center;">
      <iframe id="termsFrame" 
              src="https://docs.google.com/document/d/e/2PACX-1vSjM9eBeZvqaRzGIlyjPiB_CuhSBSjsJY7eahzMxoch-WQGLwrOcUAaDv1cxOll720J8bib1XoGlG8J/pub?embedded=true" 
              style="width: 100%;
height: 100%; border: none; border-radius: var(--radius-md); max-width: 840px;">
      </iframe>
    </div>
    <div class="card-body" style="flex-shrink: 0; padding-top: 1rem; border-top: 1px solid var(--border-color);">
      
      <div id="termsModalActivationButtons" style="display: none;">
        <div class="button-group" style="display: flex; gap: 1rem; justify-content: space-between; align-items: center;">
          <button id="confirmModal5" class="btn btn-primary" style="flex-grow: 1;">利用条件に同意して開始</button>
          <div class="purchase-select-wrapper" style="display: none;">
            <select id="purchaseModal5" class="purchase-select">
              <option value="">購入者の方</option>
              <option value="purchased">定価で購入した</option>
            </select>
          </div>
        </div>
      </div>

      <div id="termsModalStandardButtons" style="display: block;">
        <button id="closeTermsModalBtn" class="btn btn-primary" style="width: 100%;">閉じる</button>
      </div>

    </div>
  </div>
</div>


  <div id="summaryPromptModal" class="modal-overlay" style="display: none; z-index: 10010;">
    <div class="modal-content card" style="max-width: 600px;">
      <h3 class="card-header">要約スライドの追加</h3>



      <div class="card-body">
        <p style="margin-bottom: 0.5rem; margin-top: 0; line-height: 1.4; color: var(--text-secondary); font-size: 0.9rem;">
          Geminiで生成した要約画像をスライドに追加します。
        </p>
        
        <div style="display: grid; grid-template-columns: 200px 1fr; gap: 1rem; margin-bottom: 1.5rem; align-items: start;">
            
            <div style="display: flex; flex-direction: column; gap: 0.5rem;">
                <button id="copySummaryOnlyBtn" class="btn btn-primary" style="justify-content: flex-start; text-align: left; padding: 0.6rem 1rem; font-size: 0.9rem;">
                    ① プロンプトをコピー
                </button>
                <a href="https://gemini.google.com/app" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="justify-content: flex-start; text-align: left; text-decoration: none; padding: 0.6rem 1rem; font-size: 0.9rem; display: flex; align-items: center;">
                    ② Geminiで画像生成
                </a>
            </div>
            
            <textarea id="summaryPromptTextarea" class="form-control" rows="4" readonly 
              style="font-family: var(--font-family-code); font-size: 0.75rem; background: #f9f9f9; resize: none; height: 100%; min-height: 85px; margin-bottom: 0;"></textarea>
        </div>

        <div style="border-top: 1px solid var(--border-color); margin-bottom: 1.5rem;"></div>

        <p style="font-weight: 600; margin-bottom: 0.5rem;">③ 画像をアップロード（フルサイズ画像推奨）</p>
        
        <div id="summaryDropZone" class="drop-zone" style="padding: 1.5rem; transition: background-color 0.2s;">
          <span id="summaryDropZoneText" style="color: var(--text-secondary); font-size: 0.9rem;">
            ここに画像をドラッグ＆ドロップ<br>
            <a href="#" id="summaryFileSelectLink" style="color: var(--primary-color); text-decoration: underline;">ファイルを選択</a><br>
            または、画像をペースト (Ctrl+V)<br>
            <span style="font-size: 0.8rem; opacity: 0.8;">(エリア内ダブルクリックでクリップボードから貼付)</span>
          </span>
        </div>
        <input type="file" id="summaryImageUpload" accept="image/*" style="display: none;">

        <div class="button-group" style="display: flex; gap: 1rem; margin-top: 1.5rem;">
          <button id="closeSummaryPromptBtn" class="btn btn-secondary" style="background-color: var(--secondary-color); margin-left: auto;">閉じる</button>
        </div>
      </div>


    </div>
  </div>



  <div id="membershipModal" class="modal-overlay" style="display: none; z-index: 10003;">
  <div class="modal-content card" style="max-width: 550px; max-height: 90vh; position: relative;">
    
    <button id="closeMembershipModalBtn" class="panel-close-btn" style="display: block; z-index: 20; top: 0.5rem; right: 0.5rem;">×</button>
    
    <h3 class="card-header">noteメンバーシップのご紹介</h3>
    <div class="card-body" style="overflow-y: auto;">
      <h4 style="text-align: center; font-size: 1.2rem; margin-top: 0; margin-bottom: 0.5rem; color: var(--primary-color);">「Majincraft（マジンクラフト）」がスタート！🎉</h4>
      <p style="font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem;">
        あなたの作業時間を月10時間分（の気持ちで）削減する、超実践的な情報をお届けします。<br>
        「まじん」の分かりやすい解説と、「やまじん」のマニアックな技術ハックで、ビギナーからエキスパートまで徹底サポート！
      </p>

      <h5 style="font-size: 1rem; font-weight: 700; margin: 1.5rem 0 0.75rem 0; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">メンバー限定コンテンツ</h5>
      <ul style="padding-left: 20px; margin: 0 0 1rem 0; font-size: 0.9rem; line-height: 1.7;">
        <li>まじん式 v4.1〜 の先行アップデート</li>
        <li>GeminiやWorkspaceの実践的活用術</li>
        <li>SVG・フレーム抽出の技術解説</li>
        <li>まじん式のガチ開発秘話</li>
        <li>メンバー限定ウェビナー</li>
      </ul>
      
      <h5 style="font-size: 1rem; font-weight: 700; margin: 0 0 0.75rem 0;">2つの限定特典</h5>
      <ul style="padding-left: 20px; margin: 0; font-size: 0.9rem; line-height: 1.7;">
        <li>スライドの「クレジットなし」GASコードを提供！<br><span style="font-size: 0.8em; color: var(--text-secondary);">(社内ルールが厳しい方におすすめです)</span></li>
        <li>今後公開される単発の有料記事がすべて無料に！</li>
      </ul>

      <p style="font-weight: 700; text-align: center; margin: 2rem 0 1rem 0; font-size: 1rem;">
        あなたのスキルアップと業務効率化を全力で応援します。<br>ぜひご参加ください！
      </p>

      <div class="membership-footer" style="display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 1.5rem;">
        <div style="flex-grow: 1;">
          <p style="font-weight: 600; margin: 0 0 0.5rem 0;"> noteで詳細を見る:</p>
          <a href="https://note.com/majin_108/membership" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: underline; word-break: break-all;">
            https://note.com/majin_108/membership
          </a>
        </div>
        <div style="text-align: center; flex-shrink: 0;">
          <img src="https://lh3.google.com/u/0/d/1y1d_pP-akdeRTZGoIv7GxK86UyYnPYxh" alt="note QR Code" style="width: 100px; height: 100px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); margin: 0 auto;">
        </div>
      </div>
    </div>
  </div>
</div>




<div id="svgEditorModal" class="modal-overlay" style="display: none; z-index: 10050;">
  <div class="modal-content card" style="width: 95%; height: 90vh; max-width: 1200px; display: flex; flex-direction: column; padding: 0;">
    <div class="card-header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px;">
      <div style="display: flex; align-items: center; gap: 15px;">
        <span>SVGビジュアルエディタ</span>
        <div style="display: flex; gap: 5px;">
          <button id="svgUndoBtn" class="btn btn-secondary" style="padding: 2px 10px; font-size: 0.8rem;" title="元に戻す (Ctrl+Z)" disabled>◀ 戻る</button>
          <button id="svgRedoBtn" class="btn btn-secondary" style="padding: 2px 10px; font-size: 0.8rem;" title="やり直す (Ctrl+Y)" disabled>進む ▶</button>
        </div>
      </div>
      <div>
        <button id="svgEditorSaveBtn" class="btn btn-primary" style="width: auto; padding: 5px 15px; margin-right: 10px;">保存して閉じる</button>
        <button id="svgEditorCancelBtn" class="btn btn-secondary" style="width: auto; padding: 5px 15px;">キャンセル</button>
      </div>
    </div>

    <div class="svg-editor-body" style="flex-grow: 1; display: flex; overflow: hidden;">
      
      <div id="svgEditorCanvas" style="flex-grow: 1; background: #e5e7eb; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 20px; position: relative;">
        </div>

      <div id="svgEditorSidebar" style="width: 240px; background: #fff; border-left: 1px solid var(--border-color); padding: 15px; overflow-y: auto; overflow-x: hidden; flex-shrink: 0;">

        <div style="border-bottom: 1px solid var(--border-color); margin-bottom: 1.5rem; padding-bottom: 1rem;">
          
          <div class="form-group" style="display: none;">
            <input type="number" id="svgCanvasWidth">
            <input type="number" id="svgCanvasHeight">
            <input type="number" id="svgViewBoxX">
            <input type="number" id="svgViewBoxY">
          </div>

          <div class="form-group" style="margin-bottom: 0.5rem;">
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
              <div>
                <label style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-bottom: 2px;">Width</label>
                <input type="number" id="svgViewBoxW" class="form-control" style="padding: 5px; font-weight: bold; color: var(--primary-color);">
              </div>
              <div>
                <label style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-bottom: 2px;">Height</label>
                <input type="number" id="svgViewBoxH" class="form-control" style="padding: 5px; font-weight: bold; color: var(--primary-color);">
              </div>
            </div>
          </div>
        </div>
        
        <div id="svgEditorControls" style="display: none;">
          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>タグ名</label>
            <input type="text" id="svgEditTagName" class="form-control" disabled style="background: #f3f4f6;">
          </div>
          
          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>テキスト内容</label>
            <textarea id="svgEditText" class="form-control" rows="3"></textarea>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>全体不透明度 (Opacity)</label>
            <div style="display: flex; align-items: center; gap: 5px;">
              <input type="range" id="svgEditOpacityRange" min="0" max="1" step="0.1" style="flex-grow: 1;">
              <input type="number" id="svgEditOpacity" class="form-control" step="0.1" max="1" min="0" style="width: 60px;">
            </div>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>塗りつぶし (Fill)</label>
            <div class="input-group">
              <input type="color" id="svgEditFillColor">
              <input type="text" id="svgEditFillText" class="form-control">
            </div>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>塗りの不透明度 (Fill Opacity)</label>
            <div style="display: flex; align-items: center; gap: 5px;">
              <input type="range" id="svgEditFillOpacityRange" min="0" max="1" step="0.1" style="flex-grow: 1;">
              <input type="number" id="svgEditFillOpacity" class="form-control" step="0.1" max="1" min="0" style="width: 60px;">
            </div>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>線の色 (Stroke)</label>
            <div class="input-group">
              <input type="color" id="svgEditStrokeColor">
              <input type="text" id="svgEditStrokeText" class="form-control">
            </div>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>線の不透明度 (Stroke Opacity)</label>
            <div style="display: flex; align-items: center; gap: 5px;">
              <input type="range" id="svgEditStrokeOpacityRange" min="0" max="1" step="0.1" style="flex-grow: 1;">
              <input type="number" id="svgEditStrokeOpacity" class="form-control" step="0.1" max="1" min="0" style="width: 60px;">
            </div>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>線の太さ (Width)</label>
            <input type="number" id="svgEditStrokeWidth" class="form-control" step="0.5">
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>サイズ (拡大率)</label>
            <div style="display: flex; align-items: center; gap: 5px;">
              <input type="range" id="svgEditScaleRange" min="0.1" max="3.0" step="0.1" style="flex-grow: 1;">
              <input type="number" id="svgEditScale" class="form-control" step="0.1" style="width: 60px;">
            </div>
          </div>

          <div class="form-group" style="margin-bottom: 1.25rem;">
            <label>重なりの順序</label>
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
              <button id="svgBringFrontBtn" class="btn btn-secondary" style="padding: 8px; font-size: 0.75rem;" title="最前面へ">最前面</button>
              <button id="svgBringForwardBtn" class="btn btn-secondary" style="padding: 8px; font-size: 0.75rem;" title="一つ前へ">前へ</button>
              <button id="svgSendBackwardBtn" class="btn btn-secondary" style="padding: 8px; font-size: 0.75rem;" title="一つ後ろへ">後ろへ</button>
              <button id="svgSendBackBtn" class="btn btn-secondary" style="padding: 8px; font-size: 0.75rem;" title="最背面へ">最背面</button>
            </div>
          </div>
        </div>
        
        <div id="svgEditorNoSelection" style="margin-top: 2rem; text-align: center; color: #9ca3af;">
          選択されていません
        </div>
      </div>


    </div>
  </div>
</div>



  <script type="text/worker" id="apng-worker-script">
    // 必要なライブラリをワーカーのスレッドにインポート
    // (CDNのパスはHTMLの<head>で読み込んでいるものと一致させます)
    // メインスレッドからのメッセージを待機
    self.onmessage = function(event) {
        const { frames, delays, width, height } = event.data;

        try {
            console.log(`[Worker] APNGエンコード開始: ${frames.length}フレーム`);
            
            // メインスレッドをブロックしない、重い処理
            const apngBuffer = UPNG.encode(frames, width, height, 0, delays);
            
            console.log('[Worker] APNGエンコード完了');
            
            // 成功した結果（ArrayBuffer）をメインスレッドに送り返す
            // 2番目の引数 [apngBuffer] は、データをコピーせず所有権を移転(transfer)し、高速化します。
            self.postMessage({ status: 'success', buffer: apngBuffer }, [apngBuffer]);

        } catch (error) {
            console.error('[Worker] APNGエンコード失敗:', error);
            // 失敗したことをメインスレッドに伝える
            self.postMessage({ status: 'error', message: error.message });
        }
    };
  </script>









  <button id="appSwitcherBtn" title="ツール切り替え">
    <span class="material-icons-outlined" style="margin-right:6px; font-size:1.1em;">swap_horiz</span>
    <span id="appSwitcherLabel">majinTools</span>
  </button>

  <div id="majinToolsContainer">
    <div class="mt-app-card">
      <h1>majinTools PDF to Slides</h1>
      
      <div id="mt-drop-zone" class="drop-zone" tabindex="0">
        <input type="file" id="mt-pdf-upload" accept="application/pdf" style="display: none;">
        <span class="material-icons-outlined icon">cloud_upload</span>
        <p>ここにPDFをドラッグ＆ドロップ<br>または <span class="browse-btn" id="mt-browse-btn">ファイルを選択</span></p>
        <p style="font-size: 12px; margin-top: 8px; color: #9aa0a6;">(Ctrl+V で貼り付けも可能)</p>
      </div>

      <div id="mt-file-display" class="file-display">
        <span class="material-icons-outlined" style="font-size:18px;">description</span>
        <span id="mt-file-name">filename.pdf</span>
      </div>

      <div class="options-area">
        <label class="toggle-switch">
          <span class="toggle-label">画像を編集不可にする（背景化）</span>
          <label class="switch">
            <input type="checkbox" id="mt-bg-mode-check">
            <span class="slider"></span>
          </label>
        </label>
      </div>

      <button id="mt-convert-btn" class="mt-action-btn" disabled>スライドを作成</button>
      
      <div id="mt-status-text" class="status-text"></div>
    </div>

    <div id="mt-history-section" class="history-section" style="display: none;">
      <div class="history-header">最近作成したファイル</div>
      <div id="mt-history-list" class="history-list"></div>
    </div>
  </div>

  <script>
    // まじん式(既存) と majinTools(新規) の切り替えロジック
    (function() {
      const switcherBtn = document.getElementById('appSwitcherBtn');
      const switcherLabel = document.getElementById('appSwitcherLabel');
      const mainAppLayout = document.getElementById('app-layout'); // 既存アプリのメインコンテナ
      const mtContainer = document.getElementById('majinToolsContainer');
      
      let isMajinToolsMode = false;

      switcherBtn.addEventListener('click', function() {
        // プレビュー表示中などは切り替えを制限する場合、ここに条件を追加可能
        // 今回は「スライド生成前の画面のみ」という条件のため、プレビューが表示されているかチェック
        // (既存コードの isPreviewVisible 変数を利用)
        if (typeof isPreviewVisible !== 'undefined' && isPreviewVisible && !isMajinToolsMode) {
          // プレビュー中はアラートまたは何もしない
          // alert('スライドプレビュー中はツールを切り替えられません。');
          // return;
          // ※要望では「生成前の画面のみボタンを表示」とのことなので、
          // 下記の updateSwitcherVisibility でボタン自体を消す制御を行います。
        }

        isMajinToolsMode = !isMajinToolsMode;
        toggleMode();
      });

      function toggleMode() {
        if (isMajinToolsMode) {
          // majinTools モードへ
          mainAppLayout.style.display = 'none';
          mtContainer.style.display = 'block';
          switcherLabel.textContent = 'まじん式';
          switcherBtn.classList.add('active-mode');
          // 履歴読み込み
          if(typeof MajinTools !== 'undefined') MajinTools.loadHistory();
        } else {
          // まじん式 (通常) モードへ
          mainAppLayout.style.display = 'grid'; // 既存のdisplayスタイルに戻す
          mtContainer.style.display = 'none';
          switcherLabel.textContent = 'majinTools';
          switcherBtn.classList.remove('active-mode');
        }
      }

      // プレビュー状態を監視してボタンの表示/非表示を切り替える
      // (既存の showPreviewPane / hidePreviewPane にフックできればベストですが、
      //  簡易的にMutationObserverや定期チェック、あるいは既存関数への割り込みで実装)
      
      // 既存の showPreviewPane 関数をラップして監視
      const originalShowPreviewPane = window.showPreviewPane;
      window.showPreviewPane = function() {
        if(originalShowPreviewPane) originalShowPreviewPane.apply(this, arguments);
        updateSwitcherVisibility();
      };

      const originalHidePreviewPane = window.hidePreviewPane;
      window.hidePreviewPane = function() {
        if(originalHidePreviewPane) originalHidePreviewPane.apply(this, arguments);
        updateSwitcherVisibility();
      };

      function updateSwitcherVisibility() {
        // プレビューが表示されている(isPreviewVisible) 場合はボタンを隠す
        if (typeof isPreviewVisible !== 'undefined' && isPreviewVisible) {
          switcherBtn.style.display = 'none';
        } else {
          switcherBtn.style.display = 'inline-flex';
        }
      }
      
      // 初期チェック
      updateSwitcherVisibility();

    })();

    // majinTools の内部ロジック (名前空間 MajinTools で隔離)
    const MajinTools = (function() {
      const dropZone = document.getElementById('mt-drop-zone');
      const fileInput = document.getElementById('mt-pdf-upload');
      const browseBtn = document.getElementById('mt-browse-btn');
      const fileDisplay = document.getElementById('mt-file-display');
      const fileNameSpan = document.getElementById('mt-file-name');
      const convertBtn = document.getElementById('mt-convert-btn');
      const statusText = document.getElementById('mt-status-text');
      
      let selectedFile = null;
      let pdfDimensions = { width: 0, height: 0 };
      let generatedSlideUrl = null;
      let timerInterval = null;
      let startTime = 0;

      function init() {
        browseBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          fileInput.click();
        });

        fileInput.addEventListener('change', (e) => {
          if (e.target.files.length > 0) handleFile(e.target.files[0]);
        });

        ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
          dropZone.addEventListener(eventName, preventDefaults, false);
        });
        
        ['dragenter', 'dragover'].forEach(eventName => {
          dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
        });
        ['dragleave', 'drop'].forEach(eventName => {
          dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
        });
        
        dropZone.addEventListener('drop', (e) => {
          const dt = e.dataTransfer;
          if (dt.files.length > 0) handleFile(dt.files[0]);
        });

        // ペースト対応 (コンテナが表示されている時のみ有効にする判定を追加)
        document.addEventListener('paste', (e) => {
          const container = document.getElementById('majinToolsContainer');
          if (container.style.display === 'none') return; // 非表示なら無視

          const items = (e.clipboardData || e.originalEvent.clipboardData).items;
          for (let index in items) {
            const item = items[index];
            if (item.kind === 'file' && item.type === 'application/pdf') {
              handleFile(item.getAsFile());
              break; 
            }
          }
        });

        convertBtn.addEventListener('click', () => {
          if (generatedSlideUrl) {
            window.open(generatedSlideUrl, '_blank');
          } else {
            processPDF();
          }
        });
      }

      function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }

      async function handleFile(file) {
        if (file.type !== 'application/pdf') {
          showStatus("PDFファイルを選択してください", "error-msg");
          return;
        }
        
        selectedFile = file;
        fileNameSpan.textContent = file.name || "pasted_file.pdf";
        generatedSlideUrl = null; 
        resetButtonState();

        dropZone.style.display = 'none';
        fileDisplay.style.display = 'inline-flex';
        showStatus("");

        try {
          const arrayBuffer = await file.arrayBuffer();
          const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
          const page = await pdf.getPage(1);
          const viewport = page.getViewport({ scale: 1.0 });
          pdfDimensions.width = viewport.width;
          pdfDimensions.height = viewport.height;
        } catch (e) {
          console.error("PDF解析エラー", e);
          showStatus("PDFの読み込みに失敗しました", "error-msg");
        }
      }

      async function processPDF() {
        if (!selectedFile) return;
        const isBgMode = document.getElementById('mt-bg-mode-check').checked;
        
        startTimerAnimation(); 
        convertBtn.disabled = true; 
        showStatus("高画質画像を生成しています...", "");

        try {
          const arrayBuffer = await selectedFile.arrayBuffer();
          const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
          const numPages = pdf.numPages;
          let images = [];

          for (let i = 1; i <= numPages; i++) {
            showStatus(`画像を生成中 (${i} / ${numPages})`, "");
            const page = await pdf.getPage(i);
            const viewport = page.getViewport({ scale: 1.5 }); 
            const canvas = document.createElement('canvas');
            const context = canvas.getContext('2d');
            canvas.height = viewport.height;
            canvas.width = viewport.width;
            await page.render({ canvasContext: context, viewport: viewport }).promise;
            images.push(canvas.toDataURL('image/jpeg', 0.85)); 
          }

          showStatus("Googleスライドを作成しています...", "");

          // バックエンド関数呼び出し (関数名を mt_ プレフィックス付きに変更)
          google.script.run
            .withSuccessHandler((url) => {
              stopTimerAnimation();
              generatedSlideUrl = url; 
              convertBtn.innerText = "スライドを表示"; 
              convertBtn.classList.add('completed');   
              convertBtn.disabled = false; 
              showStatus("完了しました", "success-msg");
              setTimeout(() => window.open(url, '_blank'), 500);
              loadHistory();
            })
            .withFailureHandler((err) => {
              stopTimerAnimation();
              resetButtonState(); 
              showStatus("エラー: " + err.message, "error-msg");
            })
            .mt_createSlideFromImages(images, selectedFile.name || "Converted.pdf", pdfDimensions.width, pdfDimensions.height, isBgMode);
        } catch (error) {
          stopTimerAnimation();
          resetButtonState();
          showStatus("処理エラー: " + error.message, "error-msg");
        }
      }

      function startTimerAnimation() {
        convertBtn.classList.add('processing');
        startTime = Date.now();
        timerInterval = setInterval(() => {
          const elapsedTime = Date.now() - startTime;
          const seconds = (elapsedTime / 1000).toFixed(2); 
          convertBtn.innerText = `スライド作成中... ${seconds}s`;
        }, 30);
      }

      function stopTimerAnimation() {
        if (timerInterval) clearInterval(timerInterval);
        convertBtn.classList.remove('processing');
      }

      function resetButtonState() {
        convertBtn.disabled = false;
        convertBtn.classList.remove('processing');
        convertBtn.classList.remove('completed');
        convertBtn.innerText = "スライドを作成";
        convertBtn.style.backgroundImage = ''; 
        if(timerInterval) clearInterval(timerInterval);
      }

      function showStatus(msg, className) {
        statusText.textContent = msg;
        statusText.className = className ? `status-text ${className}` : 'status-text';
      }

      function loadHistory() {
        google.script.run
          .withSuccessHandler(renderHistory)
          .withFailureHandler((e) => {
            console.error('履歴リストの取得に失敗しました:', e && e.message ? e.message : e);
          })
          .mt_getHistoryList();
      }

      function renderHistory(historyArray) {
        const historySection = document.getElementById('mt-history-section');
        const listContainer = document.getElementById('mt-history-list');
        if (!historyArray || historyArray.length === 0) {
          historySection.style.display = 'none';
          return;
        }
        historySection.style.display = 'block';
        listContainer.innerHTML = '';
        historyArray.forEach(item => {
          const a = document.createElement('a');
          a.className = 'history-item';
          a.href = (typeof item.url === 'string' && /^https:\/\/docs\.google\.com\//.test(item.url)) ? item.url : '#';
          a.target = '_blank';
          a.innerHTML = `
            <div class="history-name">
              <span class="material-icons-outlined" style="font-size:18px;">article</span>
              ${escapeHTML(item.name)}
            </div>
            <div class="history-time">${escapeHTML(((item.timestamp || '') + '').split(' ')[0])}</div>
          `;
          listContainer.appendChild(a);
        });
      }

      // 初期化実行
      init();

      // 外部からアクセスしたい関数を公開
      return {
        loadHistory: loadHistory
      };
    })();

  </script>



  <div id="imageSplitterModal" class="modal-overlay" style="display: none; z-index: 11000;">
  <div class="modal-content card" style="width: 95%; height: auto; max-height: 95vh; max-width: 1400px; display: flex; flex-direction: column; padding: 0; overflow: hidden; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.2);">
    
    <div class="card-header" style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: #fff; border-bottom: 1px solid var(--border-color); flex-shrink: 0; gap: 10px;">

      <!-- 左: アクション群 + スコープ切替(このページのみ / 全ページ) + undo/redo -->
      <div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
        <button id="ist_reprocessBtn" class="btn btn-secondary btn-sm" style="padding: 0.4rem 1rem; font-size: 0.85rem; border-radius: 20px;">
          <span class="material-icons-outlined" style="font-size: 16px; margin-right: 4px; vertical-align: text-bottom;">refresh</span>
          再解析
        </button>

        <!-- ★[グループ] 表示切替(テキスト⇄画像)＋適用範囲(このページ/全ページ)を1枠でグループ化。ボタンは表示の切替のみで処理は走らせない。 -->
        <div class="ist-display-group" style="display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #232a35; border: 1px solid #4a525f; border-radius: 24px;">
        <span style="font-size: 0.68rem; color: #9ca3af; font-weight: 700; padding: 0 2px; white-space: nowrap;">表示切替</span>
        <button id="ist_convertAllBtn" class="btn btn-secondary btn-sm" style="padding: 0.4rem 1rem; font-size: 0.85rem; border-radius: 20px;">
          <span class="material-icons-outlined" style="font-size: 16px; margin-right: 4px; vertical-align: text-bottom;">text_fields</span>
          一括テキスト変換
        </button>

        <!-- ★[新規H] 一括画像変換: テキスト化したレイヤーを元の画像へ一括で戻す -->
        <button id="ist_revertAllBtn" class="btn btn-secondary btn-sm" style="padding: 0.4rem 1rem; font-size: 0.85rem; border-radius: 20px;">
          <span class="material-icons-outlined" style="font-size: 16px; margin-right: 4px; vertical-align: text-bottom;">image</span>
          一括画像変換
        </button>

        <!-- ★[要望3] 旧「1枚の画像として反映」チェックボックスをアクションボタン化。★ユーザー要望で非表示(ロジックは温存)。 -->
        <button id="ist_flattenBtn" class="btn btn-secondary btn-sm" style="display:none; padding: 0.4rem 1rem; font-size: 0.85rem; border-radius: 20px;">
          <span class="material-icons-outlined" style="font-size: 16px; margin-right: 4px; vertical-align: text-bottom;">image</span>
          1枚の画像として反映
        </button>

        <!-- ★[マーキー] 範囲を四角で囲んで確定→その範囲を新規テキストにする。★ユーザー要望で一旦非表示(ロジックは裏に温存)。 -->
        <button id="ist_marqueeBtn" class="btn btn-secondary btn-sm" style="display:none; padding: 0.4rem 1rem; font-size: 0.85rem; border-radius: 20px;" title="ドラッグで範囲を囲むとテキストに変換します">
          <span class="material-icons-outlined" style="font-size: 16px; margin-right: 4px; vertical-align: text-bottom;">crop_free</span>
          範囲をテキスト化
        </button>

        <!-- ★[要望3] スコープ切替: このページのみ(既定) / 全ページ -->
        <div id="ist_scopeToggle" style="display: flex; align-items: center; background: #F3F4F6; border-radius: 20px; padding: 2px;">
          <button type="button" data-scope="current" class="ist-scope-btn" style="border: none; background: #4b5563; color: #fff; font-size: 0.78rem; font-weight: 600; border-radius: 18px; padding: 5px 12px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,0.3);">このページのみ</button>
          <button type="button" data-scope="all" class="ist-scope-btn" style="border: none; background: transparent; color: #9ca3af; font-size: 0.78rem; font-weight: 600; border-radius: 18px; padding: 5px 12px; cursor: pointer;">全ページ</button>
        </div>
        </div><!-- /ist-display-group -->

        <div style="display: flex; gap: 4px; margin-left: 6px; align-items: center;">
            <button id="ist_undoBtn" class="btn btn-secondary btn-sm" style="padding: 0.4rem; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border-color); color: var(--text-secondary);" disabled title="元に戻す">
              <span class="material-icons-outlined" style="font-size: 18px;">arrow_back</span>
            </button>
            <button id="ist_redoBtn" class="btn btn-secondary btn-sm" style="padding: 0.4rem; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border-color); color: var(--text-secondary);" disabled title="やり直す">
              <span class="material-icons-outlined" style="font-size: 18px;">arrow_forward</span>
            </button>
        </div>
      </div>

      <!-- 中央: 何枚目 / 全体枚数(分数表示) -->
      <div style="display: flex; align-items: center; gap: 6px; font-size: 0.95rem; color: #374151; font-weight: 600; user-select: none; white-space: nowrap;">
        <span id="ist_pageIndicator">1 / 1</span>
      </div>

      <!-- 右: 前/次の画像(グレー) + 閉じる -->
      <div style="display: flex; align-items: center; gap: 8px;">
         <button id="ist_prevImgBtn" class="btn btn-sm" style="display: inline-flex; align-items: center; gap: 2px; background: #6B7280; color: #fff; border: none; border-radius: 20px; padding: 0.45rem 0.9rem; font-size: 0.82rem; font-weight: 600;" title="前の画像">
           <span class="material-icons-outlined" style="font-size: 18px;">chevron_left</span>前の画像
         </button>
         <button id="ist_nextImgBtn" class="btn btn-sm" style="display: inline-flex; align-items: center; gap: 2px; background: #6B7280; color: #fff; border: none; border-radius: 20px; padding: 0.45rem 0.9rem; font-size: 0.82rem; font-weight: 600;" title="次の画像">
           次の画像<span class="material-icons-outlined" style="font-size: 18px;">chevron_right</span>
         </button>

         <button id="ist_closeBtn" style="background: none; border: none; font-size: 1.8rem; color: #9CA3AF; cursor: pointer; line-height: 1; padding: 0 0 0 10px; transition: color 0.2s;">×</button>
      </div>
    </div>

    <div class="ist-canvas-wrap" style="flex: 1; display: flex; flex-direction: column; padding: 0; background-color: #ffffff; overflow: auto; position: relative; align-items: center;">
        
        <div id="ist_canvasContainer" class="hide-scrollbar" 
             style="width: 100%; height: auto; display: flex; align-items: center; justify-content: center; 
             background-color: #ffffff;">
             </div>
        
    </div>

    <div id="ist_loadingOverlay" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.9); display: none; flex-direction: column; justify-content: center; align-items: center; z-index: 100;">
        <div class="spinner" style="border: 4px solid #f3f3f3; border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin-bottom: 1rem;"></div>
        <div id="ist_loadingText" style="font-weight:600; color:#555;">処理中...</div>
    </div>

  </div>
</div>




  </body>
</html>
