// ==UserScript== // @name Game Datacards - Improved // @namespace local // @version 1.3 // @description Adds card image export/import (ZIP) to the header, a button to rotate card backs for printing and a toggle for the official 10th edition card font // @match *://game-datacards.eu/* // @grant none // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/fflate@0.8.3/umd/index.js // @downloadURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/game-datcards-improved.user.js // @updateURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/game-datcards-improved.user.js // ==/UserScript== (function () { 'use strict'; // --------------------------------------------------------------------------- // Image export / import (IndexedDB <-> ZIP) // --------------------------------------------------------------------------- const DB_NAME = 'CardImagesDB'; const STORE = 'images'; function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function getAll(db) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function putAll(db, records) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); const store = tx.objectStore(STORE); records.forEach((r) => store.put(r)); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } function blobToU8(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(new Uint8Array(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(blob); }); } function extFromType(type) { if (!type) return 'bin'; const map = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }; return map[type] || type.split('/')[1] || 'bin'; } async function doExport() { const db = await openDB(); const records = await getAll(db); if (!records.length) { alert('No images found in the database.'); return; } const files = {}; const manifest = []; for (const rec of records) { if (!(rec.image instanceof Blob)) { console.warn('Record image is not a Blob, skipping binary for', rec.id); } const ext = extFromType(rec.type || (rec.image && rec.image.type)); const imgName = `images/${rec.id}.${ext}`; if (rec.image instanceof Blob) { files[imgName] = await blobToU8(rec.image); } const meta = { ...rec }; delete meta.image; meta.__file = imgName; manifest.push(meta); } files['manifest.json'] = fflate.strToU8(JSON.stringify(manifest, null, 2)); const zipped = fflate.zipSync(files, { level: 6 }); const blob = new Blob([zipped], { type: 'application/zip' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'game-datacards-images.zip'; a.click(); URL.revokeObjectURL(url); console.log(`Exported ${records.length} images to ZIP.`); } async function doImport() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.zip,application/zip'; input.onchange = async () => { const file = input.files[0]; if (!file) return; const buf = new Uint8Array(await file.arrayBuffer()); const entries = fflate.unzipSync(buf); if (!entries['manifest.json']) { alert('No manifest.json in the ZIP. Cannot import.'); return; } const manifest = JSON.parse(fflate.strFromU8(entries['manifest.json'])); const records = []; for (const meta of manifest) { const rec = { ...meta }; const fileName = meta.__file; delete rec.__file; if (fileName && entries[fileName]) { rec.image = new Blob([entries[fileName]], { type: rec.type || 'application/octet-stream' }); rec.size = entries[fileName].byteLength; } records.push(rec); } const db = await openDB(); await putAll(db, records); alert(`Imported ${records.length} images. Reload the page to see them.`); }; input.click(); } const EXPORT_ICON = ''; const FONT_ICON = ''; const IMPORT_ICON = ''; function mkBtn(id, title, iconSvg, fn) { const b = document.createElement('button'); b.id = id; b.className = 'app-header-icon-btn'; b.title = title; b.setAttribute('aria-label', title); b.innerHTML = iconSvg; b.onclick = () => { const r = fn(); if (r && typeof r.catch === 'function') r.catch((e) => alert('Error: ' + e.message)); }; return b; } function addHeaderButtons() { if (document.getElementById('gdc-img-export')) return true; const settingsBtn = document.querySelector('.app-header-settings-btn'); if (!settingsBtn) return false; // Wrap our two buttons in a header group so spacing matches the rest. const group = document.createElement('div'); group.className = 'app-header-group'; group.appendChild(mkBtn('gdc-img-export', 'Export card images', EXPORT_ICON, doExport)); group.appendChild(mkBtn('gdc-img-import', 'Import card images', IMPORT_ICON, doImport)); group.appendChild(mkBtn('gdc-card-font', 'Card font: Conduit ITC', FONT_ICON, toggleCardFont)); // Insert the group right before the settings button's group, // so the buttons sit to the left of the cog. const settingsGroup = settingsBtn.closest('.app-header-group'); settingsGroup.parentNode.insertBefore(group, settingsGroup); syncCardFontButton(); return true; } // --------------------------------------------------------------------------- // Official 10th edition card font // --------------------------------------------------------------------------- // The font has to be installed locally; the extra names cover the different // family names the foundry/converters use for the same face. const CARD_FONT = "'ConduitITCStd', 'Conduit ITC Std', 'ConduitITC Std', 'Conduit ITC'"; const FONT_STYLE_ID = 'gdc-card-font'; const FONT_PREF_KEY = 'gdc-card-font-enabled'; // The official sizes are point sizes on a printed card, and CSS pt units say // nothing about how large the app happens to draw that card. So instead of // using pt directly, measure each card and derive how many layout pixels one // point of the printed card is worth; --gdc-pt below carries that value. // Printed datacard width, 8in. Measured back from an official card: its body // text sits at ~1.3% of the card width, which puts 7.5pt at this reference. // Raising it shrinks all card text and lowering it grows it, so it doubles as // the tuning knob for the overall type size: // localStorage.setItem('gdc-card-width-pt', 560) const CARD_WIDTH_PT = 576; function cardWidthPt() { return Number(localStorage.getItem('gdc-card-width-pt')) || CARD_WIDTH_PT; } function pt(size) { return `calc(${size} * var(--gdc-pt, 1.333px))`; } // offsetWidth is the untransformed layout width, so this stays correct // whether the app zooms cards by scaling the layout or by a CSS transform. function updateFontScale() { const reference = cardWidthPt(); document.querySelectorAll('.unit').forEach((unit) => { const width = unit.offsetWidth; if (!width) return; const value = width / reference + 'px'; if (unit.style.getPropertyValue('--gdc-pt') !== value) { unit.style.setProperty('--gdc-pt', value); } }); } function cardFontCss() { return ` .unit, .unit * { font-family: ${CARD_FONT}, sans-serif !important; } /* Unit name: extra bold 15pt, slightly tracked out as on the printed card */ .unit .name_container .name { font-weight: 800 !important; font-size: ${pt(15)} !important; letter-spacing: 0.02em !important; } /* Unit description: light italic 8pt */ .unit .name_container .subname, .unit .header .description { font-weight: 300 !important; font-style: italic !important; font-size: ${pt(8)} !important; } /* Unit values: bold 14pt */ .unit .stats_container .value, .unit .stats_container .value-text { font-weight: 700 !important; font-size: ${pt(14)} !important; } /* Section banners ("RANGED WEAPONS", "ABILITIES", "KEYWORDS:") are set a step larger than body text on the printed card, and tracked out. */ .unit .heading .title:not(.center), .unit .invul .title, .unit .footer .title { font-weight: 700 !important; font-size: ${pt(9)} !important; letter-spacing: 0.04em !important; } /* General weapon and abilities text: regular or bold 7.5pt */ .unit .stats_container .caption, .unit .heading .title.center, .unit .ability .name, .unit .ability .value, .unit .footer .value { font-weight: 700 !important; font-size: ${pt(7.5)} !important; } .unit .weapon .value, .unit .ability .title, .unit .ability .description, .unit .description-container .description { font-weight: 400 !important; font-size: ${pt(7.5)} !important; } /* Keyword lists are small caps, not the plain title case the app uses. */ .unit .footer .value { font-variant-caps: small-caps !important; } /* Weapon abilities (the keyword/rule chips): bold 5.5pt */ .unit .keyword-button, .unit .keyword-button span, .unit .rule-button, .unit .rule-button span { font-weight: 700 !important; font-size: ${pt(5.5)} !important; } `; } function isCardFontEnabled() { return localStorage.getItem(FONT_PREF_KEY) === '1'; } // Cards get re-rendered on every edit and resized by the zoom control, so the // scale has to be recomputed rather than set once. var domObserver = null; var sizeObserver = null; var scalePending = false; function scheduleFontScale() { if (scalePending) return; scalePending = true; requestAnimationFrame(() => { scalePending = false; updateFontScale(); document.querySelectorAll('.unit').forEach((unit) => sizeObserver.observe(unit)); }); } function applyCardFont(enabled) { const existing = document.getElementById(FONT_STYLE_ID); if (!enabled) { if (existing) existing.remove(); if (domObserver) { domObserver.disconnect(); sizeObserver.disconnect(); domObserver = null; sizeObserver = null; } return; } const style = existing || document.createElement('style'); style.id = FONT_STYLE_ID; style.textContent = cardFontCss(); if (!existing) document.head.appendChild(style); if (!domObserver) { // childList only: the scale is written to the cards as an inline custom // property, and watching attributes here would retrigger on our own write. sizeObserver = new ResizeObserver(scheduleFontScale); domObserver = new MutationObserver(scheduleFontScale); domObserver.observe(document.body, { childList: true, subtree: true }); } scheduleFontScale(); } function syncCardFontButton() { const btn = document.getElementById('gdc-card-font'); if (!btn) return; const on = isCardFontEnabled(); btn.style.color = on ? '#e8b923' : ''; btn.title = on ? 'Card font: Conduit ITC (on)' : 'Card font: Conduit ITC (off)'; btn.setAttribute('aria-pressed', on ? 'true' : 'false'); } function toggleCardFont() { const next = !isCardFontEnabled(); localStorage.setItem(FONT_PREF_KEY, next ? '1' : '0'); applyCardFont(next); syncCardFontButton(); } // --------------------------------------------------------------------------- // Rotate card backs for printing // --------------------------------------------------------------------------- function rotateCardBacks() { var wrappers = document.querySelectorAll('.unit-card-back-wrapper'); var count = 0; wrappers.forEach(function (wrapper) { var outer = wrapper.parentElement; if (!outer) return; var current = outer.style.rotate; if (current === '180deg') { outer.style.rotate = '0deg'; } else { outer.style.rotate = '180deg'; } count++; }); console.log('Rotated ' + count + ' card back(s).'); return count; } function createRotateButton() { var button = document.createElement('button'); button.id = 'rotate-card-backs-button'; button.textContent = 'Rotate card backs'; button.style.display = 'block'; button.style.width = '100%'; button.style.marginTop = '10px'; button.style.padding = '8px 12px'; button.style.background = '#381a3a'; button.style.color = 'white'; button.style.border = 'none'; button.style.borderRadius = '4px'; button.style.cursor = 'pointer'; button.style.fontFamily = 'sans-serif'; button.style.fontSize = '13px'; button.addEventListener('click', rotateCardBacks); return button; } function addRotateButton() { if (document.getElementById('rotate-card-backs-button')) return true; var sidebar = document.querySelector('.print-settings-scroll'); if (!sidebar) return false; sidebar.appendChild(createRotateButton()); console.log('Rotate card backs button added to the sidebar.'); return true; } // --------------------------------------------------------------------------- // Bootstrap // --------------------------------------------------------------------------- // The app renders after load, and the print sidebar only exists on the print // view, so keep watching the DOM until both mount points have been handled. applyCardFont(isCardFontEnabled()); var headerDone = addHeaderButtons(); var sidebarDone = addRotateButton(); if (!headerDone || !sidebarDone) { var observer = new MutationObserver(function () { if (!headerDone) headerDone = addHeaderButtons(); if (!sidebarDone) sidebarDone = addRotateButton(); if (headerDone && sidebarDone) observer.disconnect(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); } })();