const fs = require('fs'); const path = require('path'); const PDFDocument = require('pdfkit'); /* ============================================================ TROX – Scorepad Generator (PDF, parametrisierbar) ------------------------------------------------------------ Ausgabe als PDF. Frei einstellbar: Format, Ausrichtung, Spalten/Zeilen an Cards, Spielerzahl, Seitenrand. AUFRUF: node generate_scorepad.js [format] [orientation] [cols] [rows] [players] [margin_in] Beispiele: node generate_scorepad.js -> both, portrait, 2x3, 6 Spieler node generate_scorepad.js letter portrait 1 2 6 -> 2 große Cards/Seite node generate_scorepad.js a4 landscape 3 2 4 -> A4 quer, 6 Cards, 4 Spieler node generate_scorepad.js letter landscape 2 2 6 0.4 Parameter: format letter | a4 | both (Standard: both) orientation portrait | landscape (Standard: portrait) cols Spalten an Cards (Standard: 2) rows Zeilen an Cards (Standard: 3) players Spieler pro Card (Standard: 6) margin_in Seitenrand in Zoll (Standard: 0.4) Eigenschaften: - "Position"-Zeile WEISS (zum Eintragen der Endplatzierung, z.B. 1. / 3.) - "Name" links oben in der Kopfzelle, dezent/schwach gedruckt - TROX-Titelbalken weiß mit goldenen Verzierungen (nicht schwarz) - Bid-Kästchen kräftig gedruckt ============================================================ */ // ---------- CONFIG (Defaults; per CLI überschreibbar) ---------- const CONFIG = { cols: 2, rows: 3, players: 6, marginIn: 0.4, gapIn: 0.14, orientation: 'portrait', }; const argv = process.argv.slice(2); const MODE = (argv[0] || 'both').toLowerCase(); if (argv[1] && /^(portrait|landscape)$/.test(argv[1])) CONFIG.orientation = argv[1]; if (argv[2] !== undefined && !isNaN(+argv[2])) CONFIG.cols = Math.max(1, parseInt(argv[2])); if (argv[3] !== undefined && !isNaN(+argv[3])) CONFIG.rows = Math.max(1, parseInt(argv[3])); if (argv[4] !== undefined && !isNaN(+argv[4])) CONFIG.players = Math.max(2, parseInt(argv[4])); if (argv[5] !== undefined && !isNaN(+argv[5])) CONFIG.marginIn = Math.max(0, parseFloat(argv[5])); const PT = 72; // Punkte pro Zoll (PDF-Einheit) // Palette const C = { dark: '#111827', gold: '#AA7C11', goldLt: '#D4AF37', goldPale:'#F3E5AB', ink: '#1F2937', line: '#D1D5DB', zebra: '#FAFAFA', nameTx: '#C4C9D2', // schwach gedruckter Name bidStroke: '#6B7280', // kräftiger als vorher (#cbd5e1) grey: '#9CA3AF', }; const ROUNDS = [ 'R1 (1 Card)', 'R2 (2 Cards)', 'R3 (3 Cards)', 'R4 (4 Cards)', 'R5 (5 Cards)', 'R6 (5 Cards)', 'R7 (4 Cards)', 'R8 (3 Cards)', 'R9 (2 Cards)', 'R10 (1 Card)' ]; // ============================================================ // Eine Scorecard zeichnen (Koordinaten in PDF-Punkten) // ============================================================ function drawCard(doc, x, y, w, h, players) { const sc = Math.min(Math.max(w / 270, 0.7), 2.2); // Skalenfaktor (270pt ~ Referenz) const titleH = 24 * sc; const headerH = 22 * sc; const bodyTop = titleH + headerH; const nRows = ROUNDS.length + 1; // + Position const rowH = (h - bodyTop) / nRows; const labelColW = w * 0.26; const playerColW = (w - labelColW) / players; const fTitle = 12 * sc, fHeader = 7.5 * sc, fRound = 7 * sc, fName = 6.8 * sc, fPos = 8 * sc; const bidW = 12 * sc, bidH = 9 * sc; const radius = 6 * sc; // --- Outer frame (weißer Hintergrund) --- doc.save(); doc.roundedRect(x, y, w, h, radius).fillAndStroke('#ffffff', C.ink); doc.lineWidth(1.2).strokeColor(C.ink).roundedRect(x, y, w, h, radius).stroke(); doc.restore(); // --- Titelbalken: WEISS mit goldenen Verzierungen --- doc.save(); // dünne goldene Linien ober-/unterhalb + Mittelornamente const tcy = y + titleH / 2; doc.font('Helvetica-Bold').fontSize(fTitle).fillColor(C.gold) .text('T R O X', x, tcy - fTitle * 0.62, { width: w, align: 'center', characterSpacing: 2 * sc }); // Verzierungslinien links/rechts vom Titel const titleTextW = doc.widthOfString('T R O X', { characterSpacing: 2 * sc }); const cxp = x + w / 2; const ornGap = titleTextW / 2 + 12 * sc; const ornLen = Math.max(8 * sc, (w / 2) - ornGap - 10 * sc); doc.lineWidth(1 * sc).strokeColor(C.goldLt).opacity(0.9); // links doc.moveTo(x + 10 * sc, tcy).lineTo(x + 10 * sc + ornLen, tcy).stroke(); // rechts doc.moveTo(x + w - 10 * sc - ornLen, tcy).lineTo(x + w - 10 * sc, tcy).stroke(); // kleine Rauten an den Linienenden const diamond = (dx) => { const r = 2.4 * sc; doc.opacity(1).fillColor(C.goldLt) .moveTo(dx, tcy - r).lineTo(dx + r, tcy).lineTo(dx, tcy + r).lineTo(dx - r, tcy).fill(); }; diamond(x + 10 * sc); diamond(x + 10 * sc + ornLen); diamond(x + w - 10 * sc); diamond(x + w - 10 * sc - ornLen); doc.opacity(1); // feine goldene Trennlinie unter dem Titel doc.lineWidth(0.8 * sc).strokeColor(C.goldLt) .moveTo(x, y + titleH).lineTo(x + w, y + titleH).stroke(); doc.restore(); // --- Kopfzeile --- const hy = y + titleH; doc.save(); doc.font('Helvetica-Bold').fontSize(fHeader).fillColor(C.ink) .text('Round (Cards)', x + 6 * sc, hy + headerH / 2 - fHeader * 0.55, { width: labelColW - 8 * sc }); // Label-Spalten-Trennlinie über volle Höhe doc.lineWidth(1).strokeColor(C.ink) .moveTo(x + labelColW, hy).lineTo(x + labelColW, y + h).stroke(); for (let p = 0; p < players; p++) { const px = x + labelColW + p * playerColW; // Kopfzelle: heller Hintergrund doc.rect(px, hy, playerColW, headerH).fillColor('#F3F4F6').fill(); doc.lineWidth(0.5).strokeColor(C.ink).rect(px, hy, playerColW, headerH).stroke(); // "Name" links oben, schwach doc.font('Helvetica-Oblique').fontSize(fName).fillColor(C.nameTx) .text('Name', px + 4 * sc, hy + 3 * sc, { width: playerColW - 6 * sc }); if (p < players - 1) { doc.lineWidth(0.5).strokeColor(C.ink) .moveTo(px + playerColW, hy).lineTo(px + playerColW, y + h).stroke(); } } doc.restore(); // --- Runden-Zeilen --- for (let r = 0; r < ROUNDS.length; r++) { const ry = y + bodyTop + r * rowH; doc.save(); if (r % 2 === 1) { doc.rect(x + labelColW, ry, w - labelColW, rowH).fillColor(C.zebra).fill(); } doc.lineWidth(0.5).strokeColor(C.line).moveTo(x, ry).lineTo(x + w, ry).stroke(); doc.font('Helvetica').fontSize(fRound).fillColor(C.ink) .text(ROUNDS[r], x + 6 * sc, ry + rowH / 2 - fRound * 0.55, { width: labelColW - 8 * sc }); // Bid-Kästchen je Spieler – KRÄFTIGER Strich for (let p = 0; p < players; p++) { const px = x + labelColW + p * playerColW; doc.lineWidth(1.1 * sc).strokeColor(C.bidStroke) .roundedRect(px + 3 * sc, ry + 2.5 * sc, bidW, bidH, 1.5 * sc).stroke(); } doc.restore(); } // --- Position-Zeile: WEISS (zum Eintragen der Platzierung) --- const py = y + bodyTop + ROUNDS.length * rowH; doc.save(); // weißer Hintergrund (kein dunkler Balken mehr) doc.rect(x, py, w, rowH).fillColor('#ffffff').fill(); // kräftige Trennlinie oben drüber doc.lineWidth(1).strokeColor(C.ink).moveTo(x, py).lineTo(x + w, py).stroke(); doc.font('Helvetica-Bold').fontSize(fPos).fillColor(C.gold) .text('Position', x + 6 * sc, py + rowH / 2 - fPos * 0.55, { width: labelColW - 8 * sc }); // vertikale Spaltenlinien in der Position-Zeile for (let p = 1; p < players; p++) { const px = x + labelColW + p * playerColW; doc.lineWidth(0.5).strokeColor(C.line).moveTo(px, py).lineTo(px, py + rowH).stroke(); } doc.restore(); } // ============================================================ // Eine Seite zeichnen // ============================================================ function drawPage(doc, pageW, pageH, label) { const margin = CONFIG.marginIn * PT; const gap = CONFIG.gapIn * PT; const headerSpace = 22; const { cols, rows: rowsN, players } = CONFIG; const cardW = (pageW - 2 * margin - (cols - 1) * gap) / cols; const cardH = (pageH - 2 * margin - headerSpace - (rowsN - 1) * gap) / rowsN; // Seitenkopf doc.font('Helvetica-Bold').fontSize(11).fillColor(C.dark) .text('TROX SCOREPAD', margin, margin, { characterSpacing: 1.5, continued: false }); const gpp = cols * rowsN; doc.font('Helvetica').fontSize(7).fillColor(C.grey) .text(`${gpp} game(s) / ${label} ${CONFIG.orientation} · ${players} players · box = Bid · cell = running total · exact bid = +10`, margin, margin + 1, { width: pageW - 2 * margin, align: 'right' }); for (let r = 0; r < rowsN; r++) { for (let c = 0; c < cols; c++) { const cx = margin + c * (cardW + gap); const cy = margin + headerSpace + r * (cardH + gap); drawCard(doc, cx, cy, cardW, cardH, players); } } } // ============================================================ const FORMATS = { letter: { size: 'LETTER', label: 'Letter' }, a4: { size: 'A4', label: 'A4' }, }; const outputDir = path.join(__dirname, 'scorepad_export'); if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); function emit(key) { const fmt = FORMATS[key]; const doc = new PDFDocument({ size: fmt.size, layout: CONFIG.orientation, margin: 0, info: { Title: `TROX Scorepad (${fmt.label})`, Author: 'Bay Area Affiliates' }, }); const file = `trox_scorepad_${key}_${CONFIG.orientation}_${CONFIG.cols}x${CONFIG.rows}_${CONFIG.players}p.pdf`; const outPath = path.join(outputDir, file); doc.pipe(fs.createWriteStream(outPath)); drawPage(doc, doc.page.width, doc.page.height, fmt.label); doc.end(); console.log(` ${fmt.label} ${CONFIG.orientation} -> scorepad_export/${file}`); } console.log(`TROX – Scorepad (PDF): ${CONFIG.cols}x${CONFIG.rows} Cards, ${CONFIG.players} Spieler, ${CONFIG.orientation}, Rand ${CONFIG.marginIn}"`); if (MODE === 'letter' || MODE === 'both') emit('letter'); if (MODE === 'a4' || MODE === 'both') emit('a4'); console.log('Fertig.');