diff --git a/README.md b/README.md index efa3612..0fbb67d 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,14 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - overlay-vy för extern leaderboard-skärm - flera overlay-lägen: leaderboard, speaker och results - speaker-overlay med eventmarkörer och separata speaker-cues + - live speaker-panel i `Timing` för att slå av/på cues under pågående session - speaker-cues och klubbinfo/PDF-header styrs från `Settings` - logo-upload för overlay från `Settings` + - branding per event/race med egen logo, tagline, footer och PDF-tema i `Hantera` - extra speaker-cues för `session start`, `new best lap` och `top 3 change` - valbart PDF-tema: `classic`, `minimal`, `motorsport` - - utskrift av startlistor och resultat - - servergenererad PDF-export för startlistor, heatsheets och resultat + - utskrift av startlistor och resultat med vald branding + - servergenererad PDF-export för startlistor, heatsheets och resultat med inbäddad logo - genererade kval/finaler ärver tid och starttyp från raceformatet - finish-ljud som siren i stället för browser-röst - Sessioner: `practice`, `qualification`, `heat`, `final` diff --git a/server.js b/server.js index e413d90..88338d5 100644 --- a/server.js +++ b/server.js @@ -302,6 +302,7 @@ function normalizePdfExportPayload(input = {}) { const brandName = String(input.brandName || "JMK RB").trim(); const brandTagline = String(input.brandTagline || "Live Event").trim(); const footer = String(input.footer || "").trim(); + const logoDataUrl = String(input.logoDataUrl || "").trim(); const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase()) ? String(input.theme).toLowerCase() : "classic"; @@ -317,7 +318,7 @@ function normalizePdfExportPayload(input = {}) { })) : []; - return { title, subtitle, brandName, brandTagline, footer, theme, filename, sections }; + return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections }; } function buildSimplePdf(payload) { @@ -325,10 +326,13 @@ function buildSimplePdf(payload) { const pageHeight = 842; const marginLeft = 42; const marginTop = 56; + const marginBottom = 56; const lineHeight = 15; const maxChars = 86; + const logo = parsePdfLogo(payload.logoDataUrl); + const headerOffset = logo ? logo.displayHeight + 18 : 0; const lines = buildPdfLines(payload, maxChars); - const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop * 2) / lineHeight)); + const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop - marginBottom - headerOffset) / lineHeight)); const pages = []; for (let index = 0; index < lines.length; index += linesPerPage) { pages.push(lines.slice(index, index + linesPerPage)); @@ -338,37 +342,168 @@ function buildSimplePdf(payload) { } const objects = []; - objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); - - const kids = []; - const fontObjectId = 3 + pages.length * 2; + const pageObjectIds = []; + const contentObjectIds = []; + let nextObjectId = 3; for (let index = 0; index < pages.length; index += 1) { - const pageObjectId = 3 + index * 2; - const contentObjectId = 4 + index * 2; - kids.push(`${pageObjectId} 0 R`); - const contentStream = buildPdfContentStream(pages[index], marginLeft, pageHeight - marginTop, lineHeight); - objects.push( - `${pageObjectId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${fontObjectId} 0 R >> >> /Contents ${contentObjectId} 0 R >>\nendobj\n` - ); - objects.push(`${contentObjectId} 0 obj\n<< /Length ${Buffer.byteLength(contentStream, "utf8")} >>\nstream\n${contentStream}\nendstream\nendobj\n`); + pageObjectIds.push(nextObjectId++); + contentObjectIds.push(nextObjectId++); } - objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`); - objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`); + const fontObjectId = nextObjectId++; + const imageObjectId = logo ? nextObjectId++ : null; - let pdf = "%PDF-1.4\n"; + objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>")); + objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`)); + objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>")); + + if (logo && imageObjectId) { + objects.push( + createPdfStreamObject( + imageObjectId, + `<< /Type /XObject /Subtype /Image /Width ${logo.width} /Height ${logo.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`, + logo.data + ) + ); + } + + for (let index = 0; index < pages.length; index += 1) { + const resources = imageObjectId + ? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>` + : `<< /Font << /F1 ${fontObjectId} 0 R >> >>`; + const contentStream = Buffer.from( + buildPdfContentStream( + pages[index], + marginLeft, + pageHeight - marginTop - headerOffset, + lineHeight, + logo + ? { + x: marginLeft, + y: pageHeight - marginTop - logo.displayHeight, + width: logo.displayWidth, + height: logo.displayHeight, + } + : null + ), + "utf8" + ); + objects.push( + createPdfTextObject( + pageObjectIds[index], + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources ${resources} /Contents ${contentObjectIds[index]} 0 R >>` + ) + ); + objects.push(createPdfStreamObject(contentObjectIds[index], "<<", contentStream)); + } + + return assemblePdf(objects); +} + +function createPdfTextObject(id, body) { + return { + id, + buffer: Buffer.from(`${id} 0 obj\n${body}\nendobj\n`, "binary"), + }; +} + +function createPdfStreamObject(id, dictionaryPrefix, streamBuffer) { + return { + id, + buffer: Buffer.concat([ + Buffer.from(`${id} 0 obj\n${dictionaryPrefix} /Length ${streamBuffer.length} >>\nstream\n`, "binary"), + streamBuffer, + Buffer.from("\nendstream\nendobj\n", "binary"), + ]), + }; +} + +function assemblePdf(objects) { + const ordered = [...objects].sort((left, right) => left.id - right.id); + const chunks = [Buffer.from("%PDF-1.4\n", "binary")]; const offsets = [0]; - objects.forEach((object) => { - offsets.push(Buffer.byteLength(pdf, "utf8")); - pdf += object; + let position = chunks[0].length; + ordered.forEach((object) => { + offsets[object.id] = position; + chunks.push(object.buffer); + position += object.buffer.length; }); - const xrefOffset = Buffer.byteLength(pdf, "utf8"); - pdf += `xref\n0 ${objects.length + 1}\n`; - pdf += "0000000000 65535 f \n"; - offsets.slice(1).forEach((offset) => { - pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; - }); - pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; - return Buffer.from(pdf, "utf8"); + const xrefOffset = position; + const size = ordered.length + 1; + let xref = `xref\n0 ${size}\n0000000000 65535 f \n`; + for (let id = 1; id < size; id += 1) { + xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`; + } + chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary")); + return Buffer.concat(chunks); +} + +function parsePdfLogo(dataUrl) { + const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(dataUrl || "").trim()); + if (!match) { + return null; + } + const data = Buffer.from(match[1].replace(/\s+/g, ""), "base64"); + const dimensions = getJpegDimensions(data); + if (!dimensions) { + return null; + } + const scale = Math.min(150 / dimensions.width, 52 / dimensions.height, 1); + return { + data, + width: dimensions.width, + height: dimensions.height, + displayWidth: Math.max(1, Math.round(dimensions.width * scale)), + displayHeight: Math.max(1, Math.round(dimensions.height * scale)), + }; +} + +function getJpegDimensions(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null; + } + let offset = 2; + while (offset + 1 < buffer.length) { + while (offset < buffer.length && buffer[offset] !== 0xff) { + offset += 1; + } + while (offset < buffer.length && buffer[offset] === 0xff) { + offset += 1; + } + if (offset >= buffer.length) { + break; + } + const marker = buffer[offset]; + offset += 1; + if (marker === 0xd9 || marker === 0xda) { + break; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + continue; + } + if (offset + 1 >= buffer.length) { + break; + } + const length = buffer.readUInt16BE(offset); + if (length < 2 || offset + length > buffer.length) { + break; + } + if ( + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) + ) { + if (length >= 7) { + return { + height: buffer.readUInt16BE(offset + 3), + width: buffer.readUInt16BE(offset + 5), + }; + } + break; + } + offset += length; + } + return null; } function buildPdfLines(payload, maxChars) { @@ -473,16 +608,24 @@ function wrapPdfText(text, maxChars) { return lines; } -function buildPdfContentStream(lines, x, y, lineHeight) { +function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) { const escaped = lines.map((line) => escapePdfText(line)); - return [ + const commands = []; + if (logoPlacement) { + commands.push("q"); + commands.push(`${logoPlacement.width} 0 0 ${logoPlacement.height} ${logoPlacement.x} ${logoPlacement.y} cm`); + commands.push("/Im1 Do"); + commands.push("Q"); + } + commands.push( "BT", "/F1 11 Tf", `${lineHeight} TL`, `${x} ${y} Td`, ...escaped.map((line) => `(${line}) Tj\nT*`), - "ET", - ].join("\n"); + "ET" + ); + return commands.join("\n"); } function escapePdfText(text) { diff --git a/src/app.js b/src/app.js index 4094bc0..b750b41 100644 --- a/src/app.js +++ b/src/app.js @@ -105,6 +105,15 @@ const TRANSLATIONS = { "events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.", "events.actions": "Åtgärder", "events.manage_title": "Hantera", + "events.branding": "Branding för detta event", + "events.branding_note": "Lämna fält tomma för att ärva global branding från Inställningar. Logo används i overlay och bäddas in i PDF-export när den kan konverteras.", + "events.brand_name": "Brandnamn", + "events.brand_tagline": "Brandtext", + "events.brand_footer": "PDF-footer", + "events.brand_theme": "PDF-tema", + "events.brand_logo": "Eventlogo", + "events.branding_use_global": "Använd globalt standardtema", + "events.branding_save": "Spara branding", "events.session_name": "Sessionsnamn", "events.duration_placeholder": "Längd (min)", "events.max_cars_placeholder": "Max bilar (valfritt)", @@ -221,6 +230,8 @@ const TRANSLATIONS = { "timing.disconnected": "Frånkopplad", "timing.last_message": "Senaste meddelande", "timing.control": "Sessionkontroll", + "timing.speaker_panel": "Speaker-panel", + "timing.speaker_panel_hint": "Dessa växlar slår av/på cues direkt för pågående session och overlay utan att lämna Tidtagning.", "timing.select_session": "Välj session", "timing.set_active": "Sätt aktiv", "timing.start": "Starta", @@ -307,7 +318,7 @@ const TRANSLATIONS = { "settings.logo": "Logo / overlay", "settings.logo_upload": "Ladda logo", "settings.logo_clear": "Rensa logo", - "settings.logo_note": "Logon visas i overlay. PDF använder fortfarande textheader i denna version.", + "settings.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.", "settings.storage": "Lagring", "settings.backend_url": "Backend URL", "settings.backend_status": "Backend-status", @@ -541,6 +552,15 @@ const TRANSLATIONS = { "events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.", "events.actions": "Actions", "events.manage_title": "Manage", + "events.branding": "Branding for this event", + "events.branding_note": "Leave fields empty to inherit global branding from Settings. The logo is used in overlay and embedded in PDF exports when it can be converted.", + "events.brand_name": "Brand name", + "events.brand_tagline": "Brand tagline", + "events.brand_footer": "PDF footer", + "events.brand_theme": "PDF theme", + "events.brand_logo": "Event logo", + "events.branding_use_global": "Use global default theme", + "events.branding_save": "Save branding", "events.session_name": "Session name", "events.duration_placeholder": "Duration (min)", "events.max_cars_placeholder": "Max cars (optional)", @@ -657,6 +677,8 @@ const TRANSLATIONS = { "timing.disconnected": "Disconnected", "timing.last_message": "Last message", "timing.control": "Session Control", + "timing.speaker_panel": "Speaker panel", + "timing.speaker_panel_hint": "These toggles enable or disable cues live for the current session and overlay without leaving Timing.", "timing.select_session": "Select session", "timing.set_active": "Set Active", "timing.start": "Start", @@ -743,7 +765,7 @@ const TRANSLATIONS = { "settings.logo": "Logo / overlay", "settings.logo_upload": "Upload logo", "settings.logo_clear": "Clear logo", - "settings.logo_note": "The logo is shown in overlay. PDF still uses a text header in this version.", + "settings.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.", "settings.storage": "Storage", "settings.backend_url": "Backend URL", "settings.backend_status": "Backend status", @@ -1375,6 +1397,7 @@ function normalizeSession(session) { function normalizeEvent(event) { return { ...event, + branding: normalizeBrandingConfig(event?.branding), raceConfig: { qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points", qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3), @@ -1396,6 +1419,30 @@ function normalizeEvent(event) { }; } +function normalizeBrandingConfig(branding) { + const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase()) + ? String(branding.pdfTheme).toLowerCase() + : ""; + return { + brandName: String(branding?.brandName || "").trim(), + brandTagline: String(branding?.brandTagline || "").trim(), + pdfFooter: String(branding?.pdfFooter || "").trim(), + pdfTheme: theme, + logoDataUrl: String(branding?.logoDataUrl || "").trim(), + }; +} + +function resolveEventBranding(event) { + const local = normalizeBrandingConfig(event?.branding); + return { + brandName: local.brandName || state.settings.clubName || "JMK RB", + brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event", + pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB Live Event", + pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic", + logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "", + }; +} + function scheduleBackendSync() { clearTimeout(backendSyncTimer); backendSyncTimer = setTimeout(() => { @@ -2225,6 +2272,7 @@ function renderEventManager(eventId) { const carOptions = state.cars .map((c) => ``) .join(""); + const branding = normalizeBrandingConfig(event.branding); const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : []; if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) { selectedGridSessionId = ""; @@ -2301,6 +2349,30 @@ function renderEventManager(eventId) { +
+

${t("events.branding")}

+
+ + + + + +
+
+

${t("events.branding_note")}

+
+ + +
+ ${branding.logoDataUrl ? `
event-logo
` : ""} +
+
+ ${ event.mode === "track" ? ` @@ -2524,6 +2596,47 @@ function renderEventManager(eventId) { } `; + document.getElementById("eventBrandingForm")?.addEventListener("submit", (e) => { + e.preventDefault(); + const form = new FormData(e.currentTarget); + event.branding = normalizeBrandingConfig({ + ...event.branding, + brandName: String(form.get("brandName") || "").trim(), + brandTagline: String(form.get("brandTagline") || "").trim(), + pdfFooter: String(form.get("pdfFooter") || "").trim(), + pdfTheme: String(form.get("pdfTheme") || "").trim(), + }); + saveState(); + renderEventManager(eventId); + }); + + document.getElementById("eventLogoUpload")?.addEventListener("change", (eventInput) => { + const input = eventInput.currentTarget; + const file = input instanceof HTMLInputElement ? input.files?.[0] : null; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = () => { + event.branding = normalizeBrandingConfig({ + ...event.branding, + logoDataUrl: typeof reader.result === "string" ? reader.result : "", + }); + saveState(); + renderEventManager(eventId); + }; + reader.readAsDataURL(file); + }); + + document.getElementById("eventLogoClear")?.addEventListener("click", () => { + event.branding = normalizeBrandingConfig({ + ...event.branding, + logoDataUrl: "", + }); + saveState(); + renderEventManager(eventId); + }); + document.getElementById("sessionForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); @@ -3007,6 +3120,21 @@ function renderTiming() { +
+

${t("timing.speaker_panel")}

+
+

${t("timing.speaker_panel_hint")}

+
+ ${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue")} + ${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue")} + ${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue")} + ${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue")} + ${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue")} + ${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue")} +
+
+
+

${t("timing.leaderboard")}

@@ -3143,6 +3271,25 @@ function renderTiming() { document.getElementById("openOverlay")?.addEventListener("click", openOverlayWindow); document.getElementById("openSpeakerOverlay")?.addEventListener("click", () => openOverlayWindow("speaker")); document.getElementById("openResultsOverlay")?.addEventListener("click", () => openOverlayWindow("results")); + document.querySelectorAll("[data-speaker-setting]").forEach((node) => { + node.addEventListener("change", (event) => { + const input = event.currentTarget; + if (!(input instanceof HTMLInputElement)) { + return; + } + state.settings[input.dataset.speakerSetting] = input.checked; + saveState(); + }); + }); +} + +function renderSpeakerToggle(settingKey, labelKey) { + return ` + + `; } function renderGuide() { @@ -3269,6 +3416,7 @@ function renderOverlay() { const sessionTiming = active ? getSessionTiming(active) : null; const recent = active && result ? result.passings.slice(-8).reverse() : []; const event = active ? state.events.find((item) => item.id === active.eventId) : null; + const branding = resolveEventBranding(event); const practiceRows = event ? buildPracticeStandings(event) : []; const qualifyingRows = event ? buildQualifyingStandings(event) : []; const finalRows = event ? buildFinalStandings(event) : []; @@ -3282,10 +3430,10 @@ function renderOverlay() { ? `
- ${state.settings.logoDataUrl ? `` : ""} + ${branding.logoDataUrl ? `` : ""}

${escapeHtml(getEventName(active.eventId))}

${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}

-

${escapeHtml(getStartModeLabel(active.startMode))} • ${escapeHtml(modeLabel)}

+

${escapeHtml(branding.brandName)} • ${escapeHtml(getStartModeLabel(active.startMode))} • ${escapeHtml(modeLabel)}

${formatCountdown(sessionTiming?.remainingMs ?? 0)}
@@ -3600,6 +3748,18 @@ function renderSettings() { ${t("settings.speaker_finish_cue")} + + + @@ -3712,6 +3872,9 @@ function renderSettings() { state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on"; state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on"; state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on"; + state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on"; + state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on"; + state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on"; state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB"; state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Event"; state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by JMK RB Live Event"; @@ -5140,7 +5303,20 @@ function renderFinalMatrix(event) { `; } +function buildPrintBrandBlock(branding) { + return ` + + `; +} + function buildRaceStartListsHtml(event) { + const branding = resolveEventBranding(event); const sessions = getSessionsForEvent(event.id) .filter((session) => session.mode === "race") .sort((left, right) => { @@ -5152,8 +5328,16 @@ function buildRaceStartListsHtml(event) { }); return ` -

${escapeHtml(event.name)}

-

${escapeHtml(getClassName(event.classId))} • ${escapeHtml(event.date || "-")}

+

${t("events.start_lists")}

${sessions .map((session) => { @@ -5186,6 +5370,7 @@ function buildRaceStartListsHtml(event) { } function buildRaceResultsHtml(event) { + const branding = resolveEventBranding(event); return `