Generate a printable PDF of your sealed/draft deck from Draftsim, arranged as 3x3 grids on standard US Letter paper
// ==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);
}
})();