Sign in

Sign in to record a course outline. Your name will be printed at the bottom of the PDF.

New here? Create an account
or

Sign in

Sign in to record a course outline. Your name will be printed at the bottom of the PDF.

New here? Create an account
or
apiKey: "AIzaSyBwwHJVmQl5-peUV3xlBy11ZPAsFWP3JJQ", authDomain: "outline-39278.firebaseapp.com", projectId: "outline-39278", storageBucket: "outline-39278.firebasestorage.app", messagingSenderId: "943958972514", appId: "1:943958972514:web:5a10b4222c1ce6f32c193b", measurementId: "G-L5VFB76Q0Q" }; const GROQ_API_KEY = "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"; const PREFERRED_MODELS = [ "meta-llama/llama-4-scout-17b-16e-instruct", "meta-llama/llama-4-maverick-17b-128e-instruct", "llama-3.2-90b-vision-preview", "llama-3.2-11b-vision-preview" ]; const MODEL_KEY = "copdf.model.v1"; // ⭐ Edit A: current class is held in state (not a const), so the // dropdown can change it. let CLASS_ID = "VIII C"; const SCHOOL_CONFIG = { schoolName: "Pinkz Public School-CBSE", logoUrl: "logo.png", address: "" }; // ============================================================ // Init Firebase // ============================================================ firebase.initializeApp(firebaseConfig); const auth = firebase.auth(); const db = firebase.firestore(); const googleProvider = new firebase.auth.GoogleAuthProvider(); googleProvider.setCustomParameters({ prompt: "select_account" }); function saveModel(id){ try { localStorage.setItem(MODEL_KEY, id); } catch(e){} } function loadModel(){ try { return localStorage.getItem(MODEL_KEY) || ""; } catch(e){ return ""; } } function pad2(n){ return (n < 10 ? "0" : "") + n; } function todayDdMmYy(){ const d = new Date(); return pad2(d.getDate()) + "." + pad2(d.getMonth() + 1) + "." + String(d.getFullYear()).slice(-2); } function todayPretty(){ const d = new Date(); return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); } const MONTH_NAMES = [ "JANUARY","FEBRUARY","MARCH","APRIL","MAY","JUNE", "JULY","AUGUST","SEPTEMBER","OCTOBER","NOVEMBER","DECEMBER" ]; function currentMonthYearShort(){ const d = new Date(); const yy = String(d.getFullYear()).slice(-2); return MONTH_NAMES[d.getMonth()] + " " + yy; } function buildDefaultTitle(){ return "Grade " + CLASS_ID + " Course Outline \u2013 " + currentMonthYearShort(); } // ============================================================ // DOM refs // ============================================================ const authWrap = document.getElementById('authWrap'); const authBox = document.getElementById('authBox'); const authTitle = document.getElementById('authTitle'); const authSub = document.getElementById('authSub'); const authEmail = document.getElementById('authEmail'); const authPass = document.getElementById('authPass'); const authErr = document.getElementById('authErr'); const authEmailBtn = document.getElementById('authEmailBtn'); const authGoogleBtn = document.getElementById('authGoogleBtn'); const authGoogleLabel = document.getElementById('authGoogleLabel'); const authSwitchText = document.getElementById('authSwitchText'); const authSwitchLink = document.getElementById('authSwitchLink'); const nameBox = document.getElementById('nameBox'); const nameInput = document.getElementById('nameInput'); const nameErr = document.getElementById('nameErr'); const nameSaveBtn = document.getElementById('nameSaveBtn'); const appBody = document.getElementById('appBody'); const whoAmI = document.getElementById('whoAmI'); const logoutBtn = document.getElementById('logoutBtn'); const changeNameBtn = document.getElementById('changeNameBtn'); const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('fileInput'); const previewImg = document.getElementById('previewImg'); const extractBtn = document.getElementById('extractBtn'); const statusMsg = document.getElementById('statusMsg'); const errMsg = document.getElementById('errMsg'); const editCard = document.getElementById('editCard'); const rowsBody = document.getElementById('rowsBody'); const addRowBtn = document.getElementById('addRowBtn'); const pdfBtn = document.getElementById('pdfBtn'); const pdfStatus = document.getElementById('pdfStatus'); const modelSelect = document.getElementById('modelSelect'); const refreshModelsBtn = document.getElementById('refreshModelsBtn'); const modelStatus = document.getElementById('modelStatus'); const gradeBoxValue = document.getElementById('gradeBoxValue'); const classSelect = document.getElementById('classSelect'); const pdfRenderRoot = document.getElementById('pdfRenderRoot'); // ============================================================ // Auth UI state // ============================================================ let authMode = "signin"; function setAuthMode(mode){ authMode = mode; if (mode === "signin") { authTitle.textContent = "Sign in"; authSub.textContent = "Sign in to record a course outline. Your name will be printed at the bottom of the PDF."; authEmailBtn.textContent = "Sign in"; authGoogleLabel.textContent = "Continue with Google"; authSwitchText.textContent = "New here?"; authSwitchLink.textContent = "Create an account"; authPass.autocomplete = "current-password"; } else { authTitle.textContent = "Create your account"; authSub.textContent = "Make a new account to start recording course outlines."; authEmailBtn.textContent = "Create account"; authGoogleLabel.textContent = "Sign up with Google"; authSwitchText.textContent = "Already have an account?"; authSwitchLink.textContent = "Sign in"; authPass.autocomplete = "new-password"; } authErr.textContent = ""; } authSwitchLink.addEventListener('click', function(){ setAuthMode(authMode === "signin" ? "signup" : "signin"); }); function showApp(teacherName){ whoAmI.textContent = teacherName; appBody.classList.remove('hidden'); authWrap.classList.add('hidden'); const metaTeacher = document.getElementById('metaTeacher'); if (metaTeacher && !metaTeacher.value) metaTeacher.value = teacherName; const metaDate = document.getElementById('metaDate'); if (metaDate && !metaDate.value) metaDate.value = todayDdMmYy(); const metaSchool = document.getElementById('metaSchool'); if (metaSchool && !metaSchool.value) metaSchool.value = SCHOOL_CONFIG.schoolName; const metaTitle = document.getElementById('metaTitle'); if (metaTitle && !metaTitle.value) metaTitle.value = buildDefaultTitle(); if (gradeBoxValue) gradeBoxValue.textContent = CLASS_ID; refreshModelList(); } function showAuth(){ appBody.classList.add('hidden'); authWrap.classList.remove('hidden'); authBox.classList.remove('hidden'); nameBox.classList.add('hidden'); } function showNamePrompt(initial){ authBox.classList.add('hidden'); nameBox.classList.remove('hidden'); nameInput.value = initial || ""; nameErr.textContent = ""; setTimeout(function(){ nameInput.focus(); }, 50); } authEmailBtn.addEventListener('click', async function(){ authErr.textContent = ""; const email = (authEmail.value || "").trim(); const pass = authPass.value || ""; if (!email || !pass) { authErr.textContent = "Please enter both email and password."; return; } authEmailBtn.disabled = true; try { if (authMode === "signup") { await auth.createUserWithEmailAndPassword(email, pass); } else { await auth.signInWithEmailAndPassword(email, pass); } } catch (e) { authErr.textContent = (e && e.message) ? e.message : String(e); } finally { authEmailBtn.disabled = false; } }); authGoogleBtn.addEventListener('click', async function(){ authErr.textContent = ""; authGoogleBtn.disabled = true; try { await auth.signInWithPopup(googleProvider); } catch (e) { authErr.textContent = (e && e.message) ? e.message : String(e); } finally { authGoogleBtn.disabled = false; } }); nameSaveBtn.addEventListener('click', async function(){ const v = (nameInput.value || "").trim(); if (!v) { nameErr.textContent = "Please enter a name."; return; } nameErr.textContent = ""; nameSaveBtn.disabled = true; try { const u = auth.currentUser; if (u) { await db.collection("users").doc(u.uid).set({ name: v, email: u.email || "", updatedAt: firebase.firestore.FieldValue.serverTimestamp() }, { merge: true }); } showApp(v); } catch (e) { nameErr.textContent = (e && e.message) ? e.message : String(e); } finally { nameSaveBtn.disabled = false; } }); changeNameBtn.addEventListener('click', function(){ showNamePrompt(whoAmI.textContent || ""); }); logoutBtn.addEventListener('click', async function(){ try { await auth.signOut(); } catch(e){} }); auth.onAuthStateChanged(async function(user){ if (!user) { setAuthMode("signin"); showAuth(); return; } try { const snap = await db.collection("users").doc(user.uid).get(); if (snap.exists) { const data = snap.data(); if (data && data.name) { showApp(data.name); return; } } showNamePrompt(user.displayName || ""); } catch (e) { console.error("Firestore read failed:", e); showNamePrompt(user.displayName || ""); } }); async function fetchAvailableModels(){ try { const r = await fetch("https://api.groq.com/openai/v1/models", { headers: { "Authorization": "Bearer " + GROQ_API_KEY } }); if (!r.ok) throw new Error("HTTP " + r.status); const data = await r.json(); return (data && data.data) || []; } catch(e) { console.error("fetchAvailableModels failed:", e); return []; } } function pickPreferredModel(ids){ for (let i = 0; i < PREFERRED_MODELS.length; i++) { if (ids.indexOf(PREFERRED_MODELS[i]) !== -1) return PREFERRED_MODELS[i]; } const visionGuess = ids.find(function(id){ return /vision|llama-4|scout|maverick/i.test(id); }); if (visionGuess) return visionGuess; return ids[0] || ""; } async function refreshModelList(){ modelStatus.classList.add('hidden'); if (GROQ_API_KEY.indexOf("REPLACE") === 0) { modelSelect.innerHTML = ''; return; } modelSelect.innerHTML = ''; const models = await fetchAvailableModels(); if (!models.length) { modelSelect.innerHTML = ''; modelStatus.textContent = "Could not list Groq models. Check your API key."; modelStatus.classList.remove('hidden'); return; } const ids = models.map(function(m){ return m.id; }).sort(); const saved = loadModel(); const preferred = pickPreferredModel(ids); const initial = (saved && ids.indexOf(saved) !== -1) ? saved : preferred; modelSelect.innerHTML = ''; ids.forEach(function(id){ const opt = document.createElement('option'); opt.value = id; opt.textContent = id; modelSelect.appendChild(opt); }); modelSelect.value = initial; saveModel(initial); modelStatus.textContent = "Loaded " + ids.length + " model(s). Using: " + initial; modelStatus.classList.remove('hidden'); } modelSelect.addEventListener('change', function(){ saveModel(modelSelect.value); modelStatus.textContent = "Model set to: " + modelSelect.value; modelStatus.classList.remove('hidden'); }); refreshModelsBtn.addEventListener('click', refreshModelList); // ============================================================ // ⭐ Edit A: when the user changes the class dropdown, update // the on-screen box, the title, the header, and remember the // choice for the next PDF. // ============================================================ classSelect.addEventListener('change', function(){ CLASS_ID = classSelect.value; gradeBoxValue.textContent = CLASS_ID; document.getElementById('metaTitle').value = buildDefaultTitle(); }); // ============================================================ // Photo handling // ============================================================ let base64Data = null, mimeType = null; fileInput.addEventListener('change', function(e){ handleFile(e.target.files[0]); }); function handleFile(file){ if (!file) return; const reader = new FileReader(); reader.onload = function(){ const img = new Image(); img.onload = function(){ const maxDim = 1568; let w = img.width, h = img.height; if (w > maxDim || h > maxDim) { if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; } else { w = Math.round(w * maxDim / h); h = maxDim; } } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); const resizedDataUrl = canvas.toDataURL('image/jpeg', 0.85); base64Data = resizedDataUrl.split(',')[1]; mimeType = 'image/jpeg'; previewImg.src = resizedDataUrl; previewImg.classList.remove('hidden'); extractBtn.classList.remove('hidden'); errMsg.classList.add('hidden'); }; img.src = reader.result; }; reader.readAsDataURL(file); } // ============================================================ // Editable table // ============================================================ function escapeHtml(s){ return (s + '').replace(/[&<>]/g, function(c){ return ({'&':'&','<':'<','>':'>'})[c]; }); } function addRow(subject, topic, assignments){ const tr = document.createElement('tr'); tr.draggable = true; tr.innerHTML = '' + '⋯⋯' + '' + '' + '' + '' + escapeHtml(subject || '') + '' + '' + escapeHtml(topic || '') + '' + '' + escapeHtml(assignments || '') + ''; bindRowEvents(tr); rowsBody.appendChild(tr); refreshReorderButtons(); } function bindRowEvents(tr){ tr.querySelectorAll('.reorder-btn').forEach(function(btn){ btn.addEventListener('click', function(e){ e.stopPropagation(); const act = btn.getAttribute('data-act'); if (act === 'up' && tr.previousElementSibling) tr.parentNode.insertBefore(tr, tr.previousElementSibling); if (act === 'down' && tr.nextElementSibling) tr.parentNode.insertBefore(tr.nextElementSibling, tr); refreshReorderButtons(); }); }); tr.addEventListener('dragstart', function(e){ tr.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'row'); }); tr.addEventListener('dragend', function(){ tr.classList.remove('dragging'); rowsBody.querySelectorAll('tr').forEach(function(r){ r.classList.remove('drop-target-above'); r.classList.remove('drop-target-below'); }); }); tr.addEventListener('dragover', function(e){ e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = tr.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; tr.classList.toggle('drop-target-above', before); tr.classList.toggle('drop-target-below', !before); }); tr.addEventListener('dragleave', function(){ tr.classList.remove('drop-target-above'); tr.classList.remove('drop-target-below'); }); tr.addEventListener('drop', function(e){ e.preventDefault(); const dragging = rowsBody.querySelector('tr.dragging'); if (!dragging || dragging === tr) return; const rect = tr.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; tr.classList.remove('drop-target-above'); tr.classList.remove('drop-target-below'); if (before) rowsBody.insertBefore(dragging, tr); else rowsBody.insertBefore(dragging, tr.nextSibling); refreshReorderButtons(); }); } function refreshReorderButtons(){ const rows = rowsBody.querySelectorAll('tr'); rows.forEach(function(r, i){ const up = r.querySelector('.reorder-btn[data-act="up"]'); const down = r.querySelector('.reorder-btn[data-act="down"]'); if (up) up.disabled = (i === 0); if (down) down.disabled = (i === rows.length - 1); }); } addRowBtn.addEventListener('click', function(){ addRow('','',''); }); // ============================================================ // Groq vision call // ============================================================ extractBtn.addEventListener('click', async function(){ if (GROQ_API_KEY.indexOf("REPLACE") === 0) { errMsg.textContent = "Groq API key is not set."; errMsg.classList.remove('hidden'); return; } const modelId = modelSelect.value; if (!modelId) { errMsg.textContent = "No model selected. Click 'Refresh' to load models."; errMsg.classList.remove('hidden'); return; } extractBtn.disabled = true; statusMsg.textContent = 'Reading the photo…'; statusMsg.classList.remove('hidden'); errMsg.classList.add('hidden'); const prompt = 'You are a careful OCR assistant for handwritten school course outlines. ' + 'Look at the photo and return STRICT JSON only, no commentary, no markdown fences. ' + 'The JSON must match exactly this shape: ' + '{ "school": "", "title": "", "date": "", "teacher": "", "rows": [ {"subject":"", "notes":"", "assignments":""} ] }. ' + 'Rules: ' + '(1) Transcribe every word in the SAME SCRIPT as in the photo (Tamil, Hindi, Malayalam, English, etc.). ' + 'NEVER transliterate, translate, or replace text with a script name. ' + 'If you cannot read a character, output "?" for that character only. ' + '(2) Preserve the original subject code/abbreviation (e.g. "Tam", "Hib", "Math", "Hindi"). ' + '(3) Keep every row in the same order. If a cell is genuinely blank, use an empty string. ' + '(4) For "date": if a date is visible, write it as it appears (e.g. "25.08.26"); else empty string. ' + '(5) For "title": the heading line. ' + 'For "school": the school name as printed. ' + 'For "teacher": the class teacher name if printed, else empty string. ' + 'Return only the JSON object, nothing else.'; try { const dataUrl = 'data:' + mimeType + ';base64,' + base64Data; const response = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + GROQ_API_KEY }, body: JSON.stringify({ model: modelId, temperature: 0, response_format: { type: "json_object" }, messages: [{ role: "user", content: [ { type: "text", text: prompt }, { type: "image_url", image_url: { url: dataUrl } } ]}] }) }); const data = await response.json(); if (!response.ok || data.error) { const msg = (data.error && data.error.message) || ("HTTP " + response.status); throw new Error(msg + " (model: " + modelId + ")"); } const text = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; if (!text) throw new Error('No response text from Groq.'); const clean = String(text).trim() .replace(/^```json/i,'').replace(/^```/,'').replace(/```$/,'').trim(); const parsed = JSON.parse(clean); const metaSchool = document.getElementById('metaSchool'); const metaTitle = document.getElementById('metaTitle'); const metaDate = document.getElementById('metaDate'); const metaTeacher = document.getElementById('metaTeacher'); if (parsed.school && parsed.school.trim()) metaSchool.value = parsed.school.trim(); else if (!metaSchool.value) metaSchool.value = SCHOOL_CONFIG.schoolName; metaTitle.value = (parsed.title || metaTitle.value || buildDefaultTitle()).trim(); if (parsed.date && parsed.date.trim()) metaDate.value = parsed.date.trim(); else if (!metaDate.value) metaDate.value = todayDdMmYy(); if (parsed.teacher && parsed.teacher.trim()) metaTeacher.value = parsed.teacher.trim(); else if (!metaTeacher.value) metaTeacher.value = whoAmI.textContent || ""; rowsBody.innerHTML = ''; (parsed.rows || []).forEach(function(r){ addRow(r.subject, r.notes, r.assignments); }); if ((parsed.rows || []).length === 0) addRow('','',''); refreshReorderButtons(); editCard.classList.remove('hidden'); statusMsg.classList.add('hidden'); editCard.scrollIntoView({behavior:'smooth'}); } catch(err){ console.error(err); statusMsg.classList.add('hidden'); errMsg.textContent = "Couldn't parse that photo: " + (err && err.message ? err.message : err) + ". If the model is wrong, pick another from the Model dropdown."; errMsg.classList.remove('hidden'); editCard.classList.remove('hidden'); if (!document.getElementById('metaDate').value) document.getElementById('metaDate').value = todayDdMmYy(); if (rowsBody.children.length === 0) addRow('','',''); refreshReorderButtons(); } finally { extractBtn.disabled = false; } }); // ============================================================ // Logo loader // ============================================================ let _logoDataUrl = null; async function loadLogoDataUrl(url){ if (_logoDataUrl) return _logoDataUrl; if (!url || url.indexOf("example.com") !== -1) return null; try { const r = await fetch(url, { mode: "cors" }); if (!r.ok) throw new Error("logo HTTP " + r.status); const blob = await r.blob(); return await new Promise(function(resolve){ const fr = new FileReader(); fr.onload = function(){ _logoDataUrl = fr.result; resolve(fr.result); }; fr.onerror = function(){ resolve(null); }; fr.readAsDataURL(blob); }); } catch(e) { console.warn("Could not load school logo:", e); return null; } } function pngDims(dataUrl){ return new Promise(function(resolve){ const img = new Image(); img.onload = function(){ resolve({ w: img.naturalWidth, h: img.naturalHeight }); }; img.onerror = function(){ resolve({ w: 0, h: 0 }); }; img.src = dataUrl; }); } // ============================================================ // ⭐ Edit B: Tamil rendering using HTML → canvas → PDF // // Instead of trying to embed a Tamil font inside jsPDF (which // is brittle), we draw the whole table as HTML inside an // off-screen container using the loaded "Noto Sans Tamil" web // font, then convert that container to a canvas, then place // the canvas image into the PDF. This always renders Tamil // correctly because the browser is doing the text shaping. // ============================================================ async function renderTableAsCanvas(rows){ // rows: [[subject, topic, assignments], ...] // Build the off-screen HTML const headerCells = ['Subject', 'Topic Dealt', 'Assignments / Test']; const colWidths = ['20%', '46%', '34%']; // sums to 100% let html = `
${headerCells.map((c, i) => ` `).join('')} ${rows.map((r, i) => ` ${r.map((cell, j) => ` `).join('')} `).join('')}
${c}
${escapeHtml(cell || '').replace(/\n/g, '
')}
`; pdfRenderRoot.innerHTML = html; // Wait for the Tamil web font to be fully ready try { await document.fonts.ready; } catch(e) {} // Convert the off-screen element to a canvas const node = pdfRenderRoot.firstElementChild; // Use html2canvas if available, otherwise use a manual SVG approach if (window.html2canvas) { return await window.html2canvas(node, { scale: 2, backgroundColor: '#ffffff', logging: false }); } // Lightweight fallback: paint each cell into a canvas manually, // using Canvas2D + the Noto Sans Tamil font that's already loaded. // This still produces Tamil text correctly because we're using // the browser's text shaping. const width = 1100; const rowH = 36; const headH = 36; const totalH = headH + rows.length * rowH; const canvas = document.createElement('canvas'); canvas.width = width * 2; canvas.height = totalH * 2; const ctx = canvas.getContext('2d'); ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, totalH); // Header ctx.fillStyle = '#e8a415'; ctx.fillRect(0, 0, width, headH); ctx.fillStyle = '#2b2308'; ctx.font = 'bold 12px "Noto Sans Tamil","Noto Sans",Arial,sans-serif'; let xCursor = 0; const colWpx = [width * 0.20, width * 0.46, width * 0.34]; headerCells.forEach((h, i) => { ctx.fillText(h.toUpperCase(), xCursor + 12, 22); xCursor += colWpx[i]; }); // Body rows ctx.font = '14px "Noto Sans Tamil","Noto Sans",Arial,sans-serif'; rows.forEach((r, i) => { const yTop = headH + i * rowH; ctx.fillStyle = (i % 2 === 0) ? '#fff' : '#fcf8ec'; ctx.fillRect(0, yTop, width, rowH); let x = 0; r.forEach((cell, j) => { ctx.fillStyle = (j === 0) ? '#282828' : '#282828'; ctx.font = (j === 0) ? 'bold 14px "Noto Sans Tamil","Noto Sans",Arial,sans-serif' : '14px "Noto Sans Tamil","Noto Sans",Arial,sans-serif'; const lines = (cell || '').split('\n'); lines.forEach((line, li) => { ctx.fillText(line, x + 12, yTop + 18 + li * 16, colWpx[j] - 24); }); x += colWpx[j]; }); }); return canvas; } // ============================================================ // PDF generation // ============================================================ pdfBtn.addEventListener('click', async function(){ if (!window.jspdf || !window.jspdf.jsPDF) { pdfStatus.textContent = "PDF library failed to load (check your internet connection)."; pdfStatus.classList.remove('hidden'); return; } pdfStatus.textContent = 'Building PDF…'; pdfStatus.classList.remove('hidden'); const jsPDF = window.jspdf.jsPDF; const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); const marginX = 40; const dateEl = document.getElementById('metaDate'); if (!dateEl.value.trim()) dateEl.value = todayDdMmYy(); const school = document.getElementById('metaSchool').value || SCHOOL_CONFIG.schoolName; const title = document.getElementById('metaTitle').value || buildDefaultTitle(); const date = dateEl.value.trim(); const teacher = (whoAmI.textContent || document.getElementById('metaTeacher').value || '').trim(); // ---- Header (logo, school, title, date) ---- const logoUrl = await loadLogoDataUrl(SCHOOL_CONFIG.logoUrl); if (logoUrl) { const dims = await pngDims(logoUrl); const targetH = 60; const targetW = dims.h ? (dims.w * targetH / dims.h) : targetH; try { doc.addImage(logoUrl, "PNG", marginX, 40, targetW, targetH); } catch(e) { console.warn("doc.addImage failed:", e); } } const centerX = pageWidth / 2; doc.setFont('helvetica','bold'); doc.setFontSize(15); doc.text(school, centerX, 60, {align:'center'}); if (SCHOOL_CONFIG.address) { doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(110); doc.text(SCHOOL_CONFIG.address, centerX, 75, {align:'center'}); doc.setTextColor(0); } doc.setFont('helvetica','bold'); doc.setFontSize(13); doc.text("COURSE OUTLINE", centerX, 92, {align:'center'}); doc.setFont('helvetica','normal'); doc.setFontSize(10); doc.text("Date: " + date, pageWidth - marginX, 60, {align:'right'}); const headerBottomY = 110; doc.setDrawColor(220); doc.line(marginX, headerBottomY, pageWidth - marginX, headerBottomY); // Grade & Section box let tableStartY = headerBottomY + 12; { const label = "GRADE & SECTION: " + CLASS_ID; doc.setFont('helvetica','bold'); doc.setFontSize(11); const textW = doc.getTextWidth(label); const padX = 10, padY = 6; const boxW = textW + padX * 2; const boxH = 22; const boxX = marginX; const boxY = headerBottomY + 14; doc.setDrawColor(0); doc.setLineWidth(1); doc.setFillColor(249,249,249); doc.rect(boxX, boxY, boxW, boxH, 'FD'); doc.setTextColor(0); doc.text(label, boxX + padX, boxY + boxH - padY); tableStartY = boxY + boxH + 12; } // ---- Table (rendered as canvas for proper Tamil) ---- const tableRows = Array.from(rowsBody.querySelectorAll('tr')).map(function(tr){ const tds = tr.querySelectorAll('td'); return [ tds[1] && tds[1].innerText.trim(), tds[2] && tds[2].innerText.trim(), tds[3] && tds[3].innerText.trim() ]; }); const tableCanvas = await renderTableAsCanvas(tableRows); // Scale the canvas to the page width const imgData = tableCanvas.toDataURL('image/png'); const maxImgW = pageWidth - marginX * 2; const aspect = tableCanvas.height / tableCanvas.width; const imgW = maxImgW; const imgH = imgW * aspect; // If the table is too tall, paginate it across multiple pages const availableH = pageHeight - tableStartY - 80; // leave room for signature if (imgH <= availableH) { doc.addImage(imgData, 'PNG', marginX, tableStartY, imgW, imgH); } else { // Simple slicing: render the image in vertical strips // (canvas-based slicing is done by extracting strips) const strips = Math.ceil(imgH / availableH); const stripHpx = tableCanvas.height / strips; for (let s = 0; s < strips; s++) { if (s > 0) { doc.addPage(); tableStartY = 40; } // create a temporary canvas for this strip const strip = document.createElement('canvas'); strip.width = tableCanvas.width; strip.height = stripHpx; const sctx = strip.getContext('2d'); sctx.drawImage(tableCanvas, 0, -s * stripHpx); const stripData = strip.toDataURL('image/png'); const stripH = imgW * (stripHpx / tableCanvas.width); doc.addImage(stripData, 'PNG', marginX, tableStartY, imgW, stripH); } } // Compute the final Y of the table end (rough) let tableEndY = tableStartY + Math.min(imgH, availableH); if (imgH > availableH) { const used = Math.ceil(imgH / availableH); tableEndY = used * (availableH + 60); // not exact, but fine for spacing } // ---- Footer signature ---- let finalY = Math.min(tableEndY, pageHeight - 80) + 36; if (finalY > pageHeight - 80) { doc.addPage(); finalY = 60; } const signX = pageWidth - marginX; const lineLen = 200; doc.setDrawColor(160); doc.line(signX - lineLen, finalY, signX, finalY); doc.setFont('helvetica','bold'); doc.setFontSize(11); doc.text(teacher || "Class Teacher", signX, finalY + 16, {align:'right'}); doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(110); doc.text("Class Teacher", signX, finalY + 30, {align:'right'}); doc.setTextColor(0); const totalPages = doc.internal.getNumberOfPages(); for (let i = 1; i <= totalPages; i++) { doc.setPage(i); doc.setFont('helvetica','normal'); doc.setFontSize(8); doc.setTextColor(150); doc.text( "Generated by Course Outline PDF • " + todayPretty(), pageWidth / 2, pageHeight - 18, {align:'center'} ); doc.setTextColor(0); } // ⭐ Filename uses the current class (the dropdown value) const fname = "GRADE " + CLASS_ID + " COURSE OUTLINE.pdf"; doc.save(fname); pdfStatus.textContent = 'Saved: ' + fname; }); })();