// auto-split module async function ensureJsPdf() { function loadScript(url) { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = url; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } async function tryLoad(urls) { for (const u of urls) { try { await loadScript(u); } catch (_) {} if (window.jspdf && window.jspdf.jsPDF) return true; } return !!(window.jspdf && window.jspdf.jsPDF); } // 1) jsPDF laden (lokal bevorzugt, dann CDN/Unpkg) if (!(window.jspdf && window.jspdf.jsPDF)) { await tryLoad([ '/vendor/jspdf/jspdf.umd.min.js', 'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js', 'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js' ]); } if (!(window.jspdf && window.jspdf.jsPDF)) return false; // 2) autotable nachladen, falls nicht vorhanden try { const test = new window.jspdf.jsPDF(); if (typeof test.autoTable !== 'function') { await tryLoad([ '/vendor/jspdf-autotable/jspdf.plugin.autotable.min.js', 'https://cdn.jsdelivr.net/npm/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js', 'https://unpkg.com/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js' ]); } } catch (_) { /* egal – PDF geht auch ohne autotable-Funktion (Fallback) */ } return true; } async function generatePdfBlob(run) { // Lade jsPDF + AutoTable falls nötig (lokal → CDN/Unpkg). Fällt ansonsten sauber auf Text-Blob zurück. const pdfReady = await ensureJsPdf(); if (!pdfReady) { const txt = 'QA Report\n' + JSON.stringify(run, null, 2); return new Blob([txt], { type: 'application/pdf' }); } const { jsPDF } = window.jspdf; const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4', compress: true }); // dynamic layout metrics for landscape A4 const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const marginL = 14, marginR = 14; const xRight = pageW - marginR; const footerY = pageH - 13; // Farben (RGB, NICHT Hex!): "muted" war zuvor unsichtbar – jetzt korrekt const muted = [102, 102, 102]; const headFill = [242, 242, 242]; const ok = [0, 140, 0], fail = [200, 0, 0], skip = [160, 160, 0]; // Optionales Logo oben rechts – proportional in 26×12 mm Box, rechtsbündig let logo = null; try { logo = await imgToDataURL('logo_light.png'); } catch {} if (logo) { // 1) Bildabmessungen ermitteln (erst jsPDF, dann Fallback über ) let iw = 0, ih = 0; try { const props = doc.getImageProperties ? doc.getImageProperties(logo) : null; if (props) { iw = props.width || 0; ih = props.height || 0; } } catch {} if (!(iw && ih)) { await new Promise((resolve) => { const img = new Image(); img.onload = () => { iw = img.naturalWidth; ih = img.naturalHeight; resolve(); }; img.onerror = () => resolve(); img.src = logo; }); } // 2) Proportional in die Box einpassen const maxW = 26, maxH = 12; const ratio = (ih && iw) ? (ih / iw) : (10 / 26); let w = maxW, h = w * ratio; if (h > maxH) { h = maxH; w = h / ratio; } // 3) Rechtsbündig relativ zur Nutzkante (xRight) const x = xRight - w; const y = 7.8; try { doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST'); } catch {} } // Titelzeile doc.setFont('helvetica', 'bold'); doc.setFontSize(18); doc.setTextColor(0, 0, 0); doc.text('QA Report', 14, 16); if (logo) { try { // Optionales Logo oben rechts – proportional in 26×12 mm Box, rechtsbündig let logo = null; try { logo = await imgToDataURL('logo_light.png'); } catch {} if (logo) { // Bildabmessungen: zuerst aus jsPDF, sonst via -Fallback let iw = 0, ih = 0; try { const props = doc.getImageProperties ? doc.getImageProperties(logo) : null; if (props) { iw = props.width || 0; ih = props.height || 0; } } catch {} if (!(iw && ih)) { await new Promise((resolve) => { const img = new Image(); img.onload = () => { iw = img.naturalWidth; ih = img.naturalHeight; resolve(); }; img.onerror = () => resolve(); img.src = logo; }); } // proportional in die Box einpassen const maxW = 26, maxH = 12; const ratio = (ih && iw) ? (ih / iw) : (10 / 26); let w = maxW, h = w * ratio; if (h > maxH) { h = maxH; w = h / ratio; } // rechtsbündig zur Nutzkante const x = xRight - w; const y = 7.8; try { doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST'); } catch {} } } catch {} } doc.setDrawColor(0, 0, 0); doc.setLineWidth(0.2); doc.line(marginL, 18, xRight, 18); // Metadaten mit Labels (sichtbar!) const meta = [ ['Modul', sanitizeText(run.module)], ['Modul-Version', sanitizeText(run.module_version)], ['PBX-Version', sanitizeText(run.pbx_version)], ['OLM-Nummer', sanitizeText(run.olm_nummer)], ['Tester', sanitizeText(run.tester)], ['DocBee', sanitizeText(run.docbee_url || '-')], ]; let y = 24; doc.setFontSize(10); meta.forEach(([k, v]) => { doc.setFont('helvetica', 'bold'); doc.setTextColor(...muted); doc.text(k + ':', 14, y); doc.setFont('helvetica', 'normal'); doc.setTextColor(0, 0, 0); doc.text(v || '—', 50, y); y += 6; }); // Zeilen aus Steps bauen const rows = []; let i = 0, group = null; (run.steps || []).forEach(s => { if ((s.kind || 'step') === 'group') { group = sanitizeText(s.title); return; } i++; rows.push({ nr: i, group: sanitizeText(group || '–'), id: sanitizeText(s.id), title: sanitizeText(s.title), expected: sanitizeText(s.expected), status: sanitizeText((s.status || '').toLowerCase()), comment: sanitizeText(s.comment), }); }); const body = rows.map(r => [r.nr, r.group, r.id, r.title, r.expected, r.status, r.comment]); const startY = Math.max(y + 4, 24); // Tabelle (autoTable) // @ts-ignore doc.autoTable({ startY, tableWidth: pageW - (marginL + marginR), tableWidth: pageW - (marginL + marginR), head: [ ['#', 'Gruppe', 'Step-ID', 'Titel', 'Erwartet', 'Status', 'Kommentar'] ], body, styles: { font: 'helvetica', fontSize: 9, cellPadding: 2, overflow: 'linebreak', valign: 'top' }, headStyles: { fillColor: headFill, textColor: [0, 0, 0], halign: 'left' }, columnStyles: { 0: { halign: 'right', cellWidth: 8 }, 1: { cellWidth: 36 }, 2: { cellWidth: 24 }, 3: { cellWidth: 62 }, 4: { cellWidth: 76 }, 5: { cellWidth: 20, halign: 'left' }, 6: { cellWidth: 36 } }, didParseCell: (d) => { // Normalize every cell fragment to PDF-safe ASCII/Latin-1 and map arrows if (d && d.cell) { const arr = Array.isArray(d.cell.text) ? d.cell.text : [String(d.cell.text ?? '')]; d.cell.text = arr.map(t => toPdfSafe(replaceSymbols(String(t)))); } if (d.section === 'body') { // Status einfärben if (d.column.index === 5) { const val = String(d.cell.raw || '').toLowerCase(); if (val === 'pass') d.cell.styles.textColor = ok; else if (val === 'fail') d.cell.styles.textColor = fail; else if (val === 'skip' || val === 'na') d.cell.styles.textColor = skip; } // "Erwartet" als Monospace + leicht grauer Hintergrund if (d.column.index === 4) { d.cell.styles.font = 'courier'; d.cell.styles.fillColor = [250, 250, 250]; } } }, margin: { left: marginL, right: marginR }, pageBreak: 'auto' }); // Footer: Seitenzahlen & Zeitstempel const pageCount = doc.getNumberOfPages(); const ts = new Date().toLocaleString(); for (let p = 1; p <= pageCount; p++) { doc.setPage(p); doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(...muted); doc.text(`Seite ${p} / ${pageCount}`, xRight, footerY, { align: 'right' }); doc.text(ts, marginL, footerY); } return doc.output('blob'); } async function printPDF() { const run = collectRun(); if (!run) return; if (!checkRequired(run)) return; renumberSteps(); const logoDataUrl = await imgToDataURL('logo_light.png').catch(() => null); const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); // ===== Funktion: badge (Arrow) ===== // Zweck: siehe Inline-Kommentare im Funktionskörper. const badge = (status) => { const map = { pass: { txt: '✅ PASS', bg: '#DCFCE7', bd: '#22c55e' }, fail: { txt: '❌ FAIL', bg: '#FEE2E2', bd: '#ef4444' }, skip: { txt: '⏭️ SKIP', bg: '#E5E7EB', bd: '#6b7280' }, blocked: { txt: '⛔ BLOCK', bg: '#FEF3C7', bd: '#f59e0b' }, '': { txt: '—', bg: '#F3F4F6', bd: '#9ca3af' } }; const m = map[status || ''] || map['']; return `${m.txt}`; }; const counts = { pass: 0, fail: 0, skip: 0, blocked: 0 }; run.steps.forEach(s => { if ((s.kind || 'step') !== 'step') return; if (counts[s.status] !== undefined) counts[s.status]++; }); const summary = `✅ ${counts.pass} ❌ ${counts.fail} ⏭️ ${counts.skip} ⛔ ${counts.blocked}`; let rows = ''; let seenGroup = false; for (const s of run.steps) { if ((s.kind || 'step') === 'group') { const t = esc(s.title || ''); const cls = `group${seenGroup ? ' pagebreak' : ''}`; rows += `${t}`; seenGroup = true; continue; } rows += ` ${esc(s.id)}
${esc(s.title)}${s.required?' 📌':''}
${esc(s.expected)} ${badge(s.status||'')} ${esc(s.comment)} ${linkify(s.evidence)} `; } const html = ` QA Report
${logoDataUrl ? `Logo` : ``}

Testprotokoll

${summary}
Legende: ✅ Pass, ❌ Fail, ⏭️ Skip, ⛔ Blocked, 📌 Pflicht
${rows}
SchrittErwartungStatusKommentarEvidenz
`; const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const w = window.open(url, '_blank', 'noopener'); if (!w) { alert('Pop-up blockiert. Bitte Pop-ups erlauben.'); URL.revokeObjectURL(url); return; } setTimeout(() => URL.revokeObjectURL(url), 10000); } function toPdfSafe(s) { return String(s).replace(/[^\x09\x0A\x0D\x20-\x7E\u00A0-\u00FF]/g, '?'); } async function imgToDataURL(src) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); resolve(c.toDataURL('image/png')); }; img.onerror = reject; img.src = src; }); }