Draftsim Proxy Printer

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

2026-02-15 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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);
    }
})();