Draftsim Proxy Printer

Generate a printable PDF of your sealed/draft deck from Draftsim, arranged as 3x3 grids on standard US Letter paper

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Draftsim Proxy Printer
// @namespace    https://draftsim.com/
// @version      1.0
// @description  Generate a printable PDF of your sealed/draft deck from Draftsim, arranged as 3x3 grids on standard US Letter paper
// @author       You
// @match        https://draftsim.com/draft.php*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      draftsim.com
// @connect      api.scryfall.com
// @connect      cards.scryfall.io
// @require      https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
// ==/UserScript==

(function () {
    'use strict';

    // ─── CONFIGURATION ──────────────────────────────────────────────
    // US Letter dimensions in mm
    const PAGE_W = 215.9;
    const PAGE_H = 279.4;

    // Standard MTG card size in mm (2.5" × 3.5")
    const CARD_W = 63;
    const CARD_H = 88;

    const COLS = 3;
    const ROWS = 3;
    const CARDS_PER_PAGE = COLS * ROWS; // 9

    // Center the grid on the page
    const MARGIN_X = (PAGE_W - COLS * CARD_W) / 2;  // ~13.45mm
    const MARGIN_Y = (PAGE_H - ROWS * CARD_H) / 2;  // ~7.7mm

    // ─── BACKGROUND TOKEN CACHE ──────────────────────────────────────
    // Maps cardName -> [{name, url}] (unique tokens for that card)
    const tokenCache = new Map();
    // Maps tokenUrl -> base64 dataUrl (pre-fetched images)
    const tokenImageCache = new Map();
    let tokenCacheReady = false;
    let tokenCachePromise = null;

    // ─── BASIC LAND DETECTION ───────────────────────────────────────
    const BASIC_LAND_NAMES = [
        'Plains', 'Island', 'Swamp', 'Mountain', 'Forest',
        'Snow-Covered_Plains', 'Snow-Covered_Island', 'Snow-Covered_Swamp',
        'Snow-Covered_Mountain', 'Snow-Covered_Forest',
        'Wastes'
    ];

    function isBasicLand(url) {
        // Check if the element has a land_N id (basic lands added by the land panel)
        // Also check by filename
        const filename = decodeURIComponent(url).split('/').pop().replace(/\.\w+$/, '');
        // Match "Plains", "Plains_1", "Island_1", etc.
        return BASIC_LAND_NAMES.some(name => {
            const re = new RegExp('^' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(_\\d+)?$', 'i');
            return re.test(filename);
        });
    }

    // ─── UI ─────────────────────────────────────────────────────────
    GM_addStyle(`
        #proxy-print-dialog {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 100000;
            background: #2a2a2a;
            color: #eee;
            border-radius: 10px;
            padding: 20px 24px;
            box-shadow: 0 8px 30px rgba(0,0,0,0.5);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            font-size: 14px;
            display: none;
            min-width: 280px;
        }
        #proxy-print-dialog h3 {
            margin: 0 0 14px 0;
            font-size: 16px;
            color: #fff;
        }
        #proxy-print-dialog label {
            display: flex;
            align-items: center;
            gap: 8px;
            cursor: pointer;
            margin-bottom: 10px;
        }
        #proxy-print-dialog input[type="checkbox"] {
            width: 16px;
            height: 16px;
            cursor: pointer;
        }
        #proxy-print-dialog .proxy-dialog-buttons {
            display: flex;
            gap: 10px;
            margin-top: 16px;
        }
        #proxy-print-dialog .proxy-dialog-buttons button {
            flex: 1;
            padding: 8px 16px;
            border: none;
            border-radius: 6px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
        }
        #proxy-generate-btn {
            background: #1a7edb;
            color: #fff;
        }
        #proxy-generate-btn:hover {
            background: #1565c0;
        }
        #proxy-generate-btn:disabled {
            background: #666;
            cursor: default;
        }
        #proxy-cancel-btn {
            background: #555;
            color: #eee;
        }
        #proxy-cancel-btn:hover {
            background: #666;
        }
        #proxy-overlay {
            position: fixed;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.5);
            z-index: 99999;
            display: none;
        }
        #proxy-status-bar {
            margin-top: 12px;
            font-size: 13px;
            color: #aaa;
            display: none;
        }
        #proxy-print-dialog input[type="text"] {
            width: 100%;
            padding: 6px 10px;
            border: 1px solid #555;
            border-radius: 5px;
            background: #383838;
            color: #eee;
            font-size: 14px;
            margin-top: 2px;
            margin-bottom: 10px;
            box-sizing: border-box;
        }
        #proxy-print-dialog input[type="text"]::placeholder {
            color: #888;
        }
        #proxy-token-status {
            position: fixed;
            bottom: 8px;
            left: 8px;
            z-index: 99998;
            background: rgba(30, 30, 30, 0.85);
            color: #aaa;
            border-radius: 6px;
            padding: 5px 10px;
            font-size: 11px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            pointer-events: none;
            transition: opacity 0.5s;
            opacity: 1;
        }
        #proxy-token-status.done {
            opacity: 0.5;
        }
        #proxy-token-status.hidden {
            opacity: 0;
        }
    `);

    function showDialog() {
        document.getElementById('proxy-overlay').style.display = 'block';
        document.getElementById('proxy-print-dialog').style.display = 'block';
        document.getElementById('proxy-generate-btn').disabled = false;
        document.getElementById('proxy-generate-btn').textContent = 'Generate PDF';
        document.getElementById('proxy-status-bar').style.display = 'none';
    }

    function hideDialog() {
        document.getElementById('proxy-overlay').style.display = 'none';
        document.getElementById('proxy-print-dialog').style.display = 'none';
    }

    function createUI() {
        // Floating token scan status
        const tokenStatus = document.createElement('div');
        tokenStatus.id = 'proxy-token-status';
        tokenStatus.textContent = '🔍 Tokens: waiting...';
        document.body.appendChild(tokenStatus);

        // Dialog overlay
        const overlay = document.createElement('div');
        overlay.id = 'proxy-overlay';
        overlay.addEventListener('click', hideDialog);
        document.body.appendChild(overlay);

        // Options dialog
        const dialog = document.createElement('div');
        dialog.id = 'proxy-print-dialog';
        dialog.innerHTML = `
            <h3>🖨️ Print Proxies</h3>
            <label style="display:block; margin-bottom: 4px;">Player Name</label>
            <input type="text" id="proxy-player-name" placeholder="Enter player name">
            <label><input type="checkbox" id="proxy-skip-basics" checked> Skip basic lands</label>
            <label><input type="checkbox" id="proxy-skip-added-lands" checked> Skip added basic lands (land panel)</label>
            <label><input type="checkbox" id="proxy-include-tokens" checked> Include tokens (3 copies each)</label>
            <div class="proxy-dialog-buttons">
                <button id="proxy-generate-btn">Generate PDF</button>
                <button id="proxy-cancel-btn">Cancel</button>
            </div>
            <div id="proxy-status-bar"></div>
        `;
        document.body.appendChild(dialog);

        dialog.querySelector('#proxy-generate-btn').addEventListener('click', () => {
            console.log('[Proxy Printer] Generate PDF button clicked');
            generatePDF().catch(err => console.error('[Proxy Printer] Unhandled error in generatePDF:', err));
        });
        dialog.querySelector('#proxy-cancel-btn').addEventListener('click', hideDialog);

        // Inject into the existing Export dropdown menu
        const exportMenu = document.getElementById('dropup-content-menu-more');
        if (exportMenu) {
            const menuItem = document.createElement('div');
            menuItem.className = 'round-button';
            menuItem.onclick = showDialog;
            menuItem.innerHTML = `
                <ion-icon class="submenu-icon icon-big" size="big" name="print"></ion-icon>
                <label>Print Proxies</label>
            `;
            exportMenu.appendChild(menuItem);
        } else {
            // Fallback: retry after a delay in case DOM isn't ready yet
            const retryInterval = setInterval(() => {
                const menu = document.getElementById('dropup-content-menu-more');
                if (menu) {
                    clearInterval(retryInterval);
                    const menuItem = document.createElement('div');
                    menuItem.className = 'round-button';
                    menuItem.onclick = showDialog;
                    menuItem.innerHTML = `
                        <ion-icon class="submenu-icon icon-big" size="big" name="print"></ion-icon>
                        <label>Print Proxies</label>
                    `;
                    menu.appendChild(menuItem);
                }
            }, 500);
            // Give up after 10s
            setTimeout(() => clearInterval(retryInterval), 10000);
        }
    }

    // ─── COLLECT DECK CARD IMAGES ───────────────────────────────────
    function getDeckCardURLs() {
        console.log('[Proxy Printer] getDeckCardURLs called');
        const skipBasics = document.getElementById('proxy-skip-basics')?.checked ?? true;
        const skipAddedLands = document.getElementById('proxy-skip-added-lands')?.checked ?? true;
        console.log('[Proxy Printer] skipBasics:', skipBasics, 'skipAddedLands:', skipAddedLands);

        const handList = document.getElementById('hand-list');
        if (!handList) {
            console.warn('[Proxy Printer] #hand-list not found in DOM');
            alert('No deck found! Make sure you\'ve built a deck first (click "Build").');
            return [];
        }

        const cards = handList.querySelectorAll('li.card-picked');
        console.log('[Proxy Printer] Found', cards.length, 'li.card-picked elements in #hand-list');
        const urls = [];

        for (const card of cards) {
            // Check if this is an added basic land (from the land panel)
            const id = card.id || '';
            const isAddedLand = /^land_\d+$/.test(id);

            if (isAddedLand && skipAddedLands) continue;

            // Extract image URL from background-image style
            const bg = card.style.backgroundImage;
            const match = bg.match(/url\(["']?(.+?)["']?\)/);
            if (!match) continue;

            let imgUrl = match[1];

            // Make absolute
            if (!imgUrl.startsWith('http')) {
                imgUrl = 'https://draftsim.com/' + imgUrl.replace(/^\//, '');
            }

            // Skip basic lands by filename if option checked
            if (skipBasics && isBasicLand(imgUrl)) continue;

            urls.push(imgUrl);
        }

        console.log('[Proxy Printer] getDeckCardURLs returning', urls.length, 'URLs');
        if (urls.length > 0) console.log('[Proxy Printer] First URL:', urls[0]);
        return urls;
    }

    // ─── IMAGE FETCHING ─────────────────────────────────────────────
    function fetchImageAsBase64(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'blob',
                onload: function (response) {
                    if (response.status !== 200) {
                        reject(new Error(`HTTP ${response.status} for ${url}`));
                        return;
                    }
                    const reader = new FileReader();
                    reader.onloadend = function () {
                        resolve(reader.result);
                    };
                    reader.onerror = reject;
                    reader.readAsDataURL(response.response);
                },
                onerror: reject
            });
        });
    }

    function fetchJSON(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'json',
                headers: { 'Accept': 'application/json' },
                onload: function (response) {
                    if (response.status !== 200) {
                        reject(new Error(`HTTP ${response.status} for ${url}`));
                        return;
                    }
                    // responseType json may auto-parse or not depending on GM implementation
                    const data = typeof response.response === 'string'
                        ? JSON.parse(response.response)
                        : response.response;
                    resolve(data);
                },
                onerror: reject
            });
        });
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // ─── SCRYFALL TOKEN LOOKUP ───────────────────────────────────────
    function extractCardName(imageUrl) {
        // Extract filename from URL like "Images/ECL/Adept_Watershaper.jpg"
        let filename = decodeURIComponent(imageUrl).split('/').pop().replace(/\.[^.]+$/, '');
        // Remove trailing _1, _2 etc. (variant numbering on basics)
        filename = filename.replace(/_\d+$/, '');
        // Replace underscores with spaces
        let name = filename.replace(/_/g, ' ');
        // Handle comma encoding ("Ashling, Rekindled")
        name = name.replace(/%2C/gi, ',');
        return name;
    }

    function updateTokenStatus(text, state) {
        const el = document.getElementById('proxy-token-status');
        if (!el) return;
        el.textContent = text;
        el.className = state || '';
    }

    // Look up a single card's tokens and cache them
    async function lookupTokensForCard(cardName) {
        if (tokenCache.has(cardName)) return;
        tokenCache.set(cardName, []); // mark as in-progress

        try {
            const url = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(cardName)}`;
            const card = await fetchJSON(url);

            if (card.all_parts) {
                const tokens = [];
                for (const part of card.all_parts) {
                    if (part.component === 'token') {
                        // Fetch the token card to get its image
                        try {
                            const tokenCard = await fetchJSON(part.uri);
                            let tokenImg = null;
                            if (tokenCard.image_uris) {
                                tokenImg = tokenCard.image_uris.normal || tokenCard.image_uris.large;
                            } else if (tokenCard.card_faces && tokenCard.card_faces[0]?.image_uris) {
                                tokenImg = tokenCard.card_faces[0].image_uris.normal || tokenCard.card_faces[0].image_uris.large;
                            }
                            if (tokenImg) {
                                tokens.push({ name: part.name, url: tokenImg });
                            }
                        } catch (e) {
                            console.warn(`Failed to fetch token "${part.name}":`, e);
                        }
                        await sleep(80);
                    }
                }
                tokenCache.set(cardName, tokens);
            }
        } catch (e) {
            console.warn(`Scryfall lookup failed for "${cardName}":`, e);
        }

        await sleep(80); // Scryfall rate limit
    }

    // Background: scan all pool cards and pre-cache tokens
    async function backgroundTokenScan() {
        console.log('[Proxy Printer] Starting background token scan...');
        updateTokenStatus('🔍 Tokens: waiting for cards...');

        // Wait for pool/deck cards to appear in the DOM
        let attempts = 0;
        while (attempts < 60) {
            const poolCards = document.querySelectorAll('#collection-container .collection-card, #hand-list .card-picked');
            if (poolCards.length > 0) break;
            await sleep(1000);
            attempts++;
        }

        // Gather all unique card image URLs from pool + deck
        const allCardEls = document.querySelectorAll('#collection-container .collection-card, #hand-list .card-picked');
        const allNames = new Set();
        for (const el of allCardEls) {
            const bg = el.style.backgroundImage;
            const match = bg.match(/url\(["']?(.+?)["']?\)/);
            if (!match) continue;
            const imgUrl = match[1];
            if (isBasicLand(imgUrl)) continue;
            allNames.add(extractCardName(imgUrl));
        }

        console.log(`[Proxy Printer] Scanning ${allNames.size} unique cards for tokens...`);

        let scanned = 0;
        for (const name of allNames) {
            scanned++;
            updateTokenStatus(`🔍 Tokens: ${scanned}/${allNames.size}`);
            await lookupTokensForCard(name);
        }

        // Pre-fetch all unique token images
        const uniqueTokenUrls = new Set();
        for (const tokens of tokenCache.values()) {
            for (const t of tokens) uniqueTokenUrls.add(t.url);
        }

        console.log(`[Proxy Printer] Pre-fetching ${uniqueTokenUrls.size} token images...`);
        let fetched = 0;
        for (const tokenUrl of uniqueTokenUrls) {
            fetched++;
            updateTokenStatus(`🖼️ Tokens: loading images ${fetched}/${uniqueTokenUrls.size}`);
            if (!tokenImageCache.has(tokenUrl)) {
                try {
                    const dataUrl = await fetchImageAsBase64(tokenUrl);
                    tokenImageCache.set(tokenUrl, dataUrl);
                } catch (e) {
                    console.warn('Failed to pre-fetch token image:', e);
                }
            }
        }

        tokenCacheReady = true;
        const totalTokens = uniqueTokenUrls.size;
        updateTokenStatus(`✅ Tokens: ${totalTokens} ready`, 'done');
        // Fade out after 8 seconds
        setTimeout(() => updateTokenStatus(`✅ Tokens: ${totalTokens} ready`, 'hidden'), 8000);
        console.log(`[Proxy Printer] Background token scan complete. ${totalTokens} token images cached.`);
    }

    // Get token images for a specific set of deck card URLs (uses cache)
    async function getTokenImagesForDeck(cardUrls, statusEl) {
        const deckNames = [...new Set(cardUrls.map(extractCardName))];
        const seenTokens = new Set();
        const tokenEntries = []; // [{name, data}]

        // Look up any cards not yet cached (shouldn't happen if background ran, but just in case)
        for (const name of deckNames) {
            if (!tokenCache.has(name)) {
                if (statusEl) statusEl.textContent = `Looking up tokens for ${name}...`;
                await lookupTokensForCard(name);
            }
        }

        // Collect unique tokens and expand to 3 copies
        for (const name of deckNames) {
            const tokens = tokenCache.get(name) || [];
            for (const token of tokens) {
                if (seenTokens.has(token.name)) continue;
                seenTokens.add(token.name);

                // Get cached image or fetch
                let data = tokenImageCache.get(token.url);
                if (!data) {
                    try {
                        data = await fetchImageAsBase64(token.url);
                        tokenImageCache.set(token.url, data);
                    } catch (e) {
                        console.warn(`Failed to load token image for "${token.name}":`, e);
                        continue;
                    }
                }

                // 3 copies
                for (let c = 0; c < 3; c++) {
                    tokenEntries.push({ name: token.name, data });
                }
            }
        }

        return tokenEntries;
    }

    // ─── PDF GENERATION ─────────────────────────────────────────────
    async function generatePDF() {
        console.log('[Proxy Printer] generatePDF() called');
        const btn = document.getElementById('proxy-generate-btn');
        const statusEl = document.getElementById('proxy-status-bar');
        console.log('[Proxy Printer] btn:', !!btn, 'statusEl:', !!statusEl);

        const urls = getDeckCardURLs();
        if (urls.length === 0) {
            console.warn('[Proxy Printer] No card URLs found, aborting');
            return;
        }
        console.log('[Proxy Printer] Got', urls.length, 'card URLs');

        btn.disabled = true;
        btn.textContent = 'Generating...';
        statusEl.style.display = 'block';
        statusEl.textContent = `Loading 0/${urls.length} images...`;

        try {
            // Fetch all images
            const images = [];
            for (let i = 0; i < urls.length; i++) {
                statusEl.textContent = `Loading ${i + 1}/${urls.length} images...`;
                try {
                    const dataUrl = await fetchImageAsBase64(urls[i]);
                    images.push(dataUrl);
                } catch (e) {
                    console.warn('Failed to load image:', urls[i], e);
                    // Use a placeholder - skip this card
                    images.push(null);
                }
            }

            const validImages = images.filter(img => img !== null);
            console.log('[Proxy Printer] validImages:', validImages.length, 'of', images.length);
            if (validImages.length === 0) {
                alert('Could not load any card images!');
                return;
            }

            // Get tokens if enabled
            const includeTokens = document.getElementById('proxy-include-tokens')?.checked ?? true;
            let tokenImages = [];
            if (includeTokens) {
                statusEl.textContent = tokenCacheReady
                    ? 'Preparing tokens from cache...'
                    : 'Looking up tokens on Scryfall...';
                try {
                    tokenImages = await getTokenImagesForDeck(urls, statusEl);
                } catch (e) {
                    console.warn('Token lookup failed, continuing without tokens:', e);
                }
            }

            statusEl.textContent = 'Building PDF...';

            // Create PDF
            console.log('[Proxy Printer] window.jspdf:', typeof window.jspdf, window.jspdf);
            const { jsPDF } = window.jspdf;
            console.log('[Proxy Printer] jsPDF constructor:', typeof jsPDF);
            const doc = new jsPDF({
                orientation: 'portrait',
                unit: 'mm',
                format: 'letter'
            });

            const playerName = (document.getElementById('proxy-player-name')?.value || '').trim();

            // Calculate how many empty slots on last deck page
            const deckPages = Math.ceil(validImages.length / CARDS_PER_PAGE);
            const lastPageDeckCards = validImages.length % CARDS_PER_PAGE || CARDS_PER_PAGE;
            const emptySlots = CARDS_PER_PAGE - lastPageDeckCards;

            // Determine how many tokens fit on last page and how many need extra pages
            const tokensForLastPage = Math.min(tokenImages.length, emptySlots);
            const tokensRemaining = tokenImages.length - tokensForLastPage;
            const extraTokenPages = tokensRemaining > 0 ? Math.ceil(tokensRemaining / CARDS_PER_PAGE) : 0;
            const totalPages = deckPages + extraTokenPages;

            let imgIdx = 0;

            // ── Render deck card pages ──
            for (let page = 0; page < deckPages; page++) {
                if (page > 0) doc.addPage();

                const isLastDeckPage = (page === deckPages - 1);
                const cardsOnThisPage = isLastDeckPage ? lastPageDeckCards : CARDS_PER_PAGE;
                const firstCard = page * CARDS_PER_PAGE + 1;
                const lastCard = page * CARDS_PER_PAGE + cardsOnThisPage;

                // Header text
                let headerText = '';
                if (playerName) {
                    headerText = `${playerName}'s deck, page ${page + 1}, cards ${firstCard}-${lastCard}`;
                } else {
                    headerText = `Page ${page + 1}, cards ${firstCard}-${lastCard}`;
                }
                doc.setFontSize(10);
                doc.setTextColor(80, 80, 80);
                doc.text(headerText, PAGE_W / 2, MARGIN_Y - 2, { align: 'center' });

                // Place deck cards
                for (let slot = 0; slot < CARDS_PER_PAGE && imgIdx < validImages.length; slot++) {
                    const col = slot % COLS;
                    const row = Math.floor(slot / COLS);
                    const x = MARGIN_X + col * CARD_W;
                    const y = MARGIN_Y + row * CARD_H;

                    try {
                        doc.addImage(validImages[imgIdx], 'JPEG', x, y, CARD_W, CARD_H);
                    } catch (e) {
                        console.warn('Failed to add image to PDF:', e);
                    }
                    imgIdx++;
                }

                // Fill empty slots on last deck page with tokens
                if (isLastDeckPage && tokensForLastPage > 0) {
                    let slot = lastPageDeckCards;
                    for (let t = 0; t < tokensForLastPage; t++, slot++) {
                        const col = slot % COLS;
                        const row = Math.floor(slot / COLS);
                        const x = MARGIN_X + col * CARD_W;
                        const y = MARGIN_Y + row * CARD_H;
                        try {
                            doc.addImage(tokenImages[t].data, 'JPEG', x, y, CARD_W, CARD_H);
                        } catch (e) {
                            console.warn('Failed to add token image:', e);
                        }
                    }
                }

                statusEl.textContent = `Page ${page + 1}/${totalPages} done`;
            }

            // ── Render extra token pages if tokens didn't all fit ──
            let tokenIdx = tokensForLastPage;
            for (let ep = 0; ep < extraTokenPages; ep++) {
                doc.addPage();
                const pageNum = deckPages + ep + 1;

                // Header
                let headerText = playerName
                    ? `${playerName}'s tokens, page ${ep + 1}`
                    : `Tokens, page ${ep + 1}`;
                doc.setFontSize(10);
                doc.setTextColor(80, 80, 80);
                doc.text(headerText, PAGE_W / 2, MARGIN_Y - 2, { align: 'center' });

                for (let slot = 0; slot < CARDS_PER_PAGE && tokenIdx < tokenImages.length; slot++, tokenIdx++) {
                    const col = slot % COLS;
                    const row = Math.floor(slot / COLS);
                    const x = MARGIN_X + col * CARD_W;
                    const y = MARGIN_Y + row * CARD_H;
                    try {
                        doc.addImage(tokenImages[tokenIdx].data, 'JPEG', x, y, CARD_W, CARD_H);
                    } catch (e) {
                        console.warn('Failed to add token image:', e);
                    }
                }

                statusEl.textContent = `Page ${pageNum}/${totalPages} done`;
            }

            // Download
            const setCode = window.location.search.match(/mode=\w+_(\w+)/)?.[1] || 'deck';
            const filename = `draftsim_proxies_${setCode}_${Date.now()}.pdf`;
            console.log('[Proxy Printer] Saving PDF as:', filename);
            doc.save(filename);

            const tokenMsg = tokenImages.length > 0 ? ` + ${tokenImages.length} token(s)` : '';
            statusEl.textContent = `✅ Done! ${validImages.length} cards${tokenMsg} on ${totalPages} page(s)`;
            setTimeout(() => { hideDialog(); }, 2500);

        } catch (err) {
            console.error('PDF generation failed:', err);
            alert('PDF generation failed: ' + err.message);
            statusEl.style.display = 'none';
        } finally {
            btn.disabled = false;
            btn.textContent = 'Generate PDF';
        }
    }

    // ─── INIT ───────────────────────────────────────────────────────
    // Wait for the page to be ready
    function init() {
        createUI();
        // Start background token scan
        tokenCachePromise = backgroundTokenScan();
        console.log('[Proxy Printer] Ready. Click the "Print Proxies" button to generate a PDF.');
    }

    if (document.readyState === 'complete') {
        init();
    } else {
        window.addEventListener('load', init);
    }
})();