(function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); } else { root.ISMIStools = Object.assign(root.ISMIStools || {}, factory()); } })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; function getPDFLib() { return (typeof self !== 'undefined' && self.PDFLib) || (typeof globalThis !== 'undefined' && globalThis.PDFLib); } function wrapText(text, font, size, maxWidth) { const words = String(text).split(/\s+/).filter(Boolean); const lines = []; let line = ''; for (const word of words) { const candidate = line ? line + ' ' + word : word; if (font.widthOfTextAtSize(candidate, size) <= maxWidth) { line = candidate; } else { if (line) lines.push(line); line = word; } } if (line) lines.push(line); return lines.length ? lines : ['']; } // Format the raw schedule string to match the official PDF style: // comma-separate day codes, trim duplicated room suffixes, and split // multiple meeting segments onto separate lines. function formatSchedule(schedule) { let s = String(schedule || '').replace(/\s+/g, ' ').trim(); if (!s) return s; // Collapse duplicated TC anywhere in the string. while (/TC\s+TC/.test(s)) s = s.replace(/TC\s+TC/g, 'TC'); // Join adjacent day codes with commas. s = s.replace(/\b(M|Tu|T|W|Th|F|Sa|Su)\s+(M|Tu|T|W|Th|F|Sa|Su)\b/g, '$1,$2'); // Split concatenated meeting segments (room ending in TC followed by a // day code) onto separate lines. s = s.replace(/(TC)\s+(?=(?:Su|Sa|Th|Tu|M|W|F|T)\b)/g, '$1\n'); return s; } // Reformat a full name to "Lastname, Firstname Middle" when the source gives // it as "Firstname Middle Lastname" (no comma). If a comma is already // present the name is returned unchanged. function formatFullName(name) { const s = String(name || '').trim(); if (!s || /,/.test(s)) return s; const parts = s.split(/\s+/); if (parts.length < 2) return s; const last = parts.pop(); return last + ', ' + parts.join(' '); } function wrapLines(text, font, size, maxWidth) { const out = []; String(text == null ? '' : text).split(/\n/).forEach((seg) => { out.push(...wrapText(seg, font, size, maxWidth)); }); return out.length ? out : ['']; } // Compact a degree name for display: // "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT". // Multi-word fields become initials; single-word fields stay title-cased. function compactProgram(program) { const s = String(program || '').trim().toUpperCase(); let m = /^BACHELOR OF SCIENCE IN (.+)$/.exec(s); let prefix = 'BS'; if (!m) { m = /^BACHELOR OF ARTS IN (.+)$/.exec(s); if (m) prefix = 'AB'; } if (!m) return String(program || '').trim(); const field = m[1]; const words = field.split(/\s+/).filter((w) => !/^(AND|OF|THE|IN)$/.test(w)); if (words.length <= 1) { const single = words[0] || field; return prefix + ' ' + single.charAt(0).toUpperCase() + single.slice(1).toLowerCase(); } return prefix + ' ' + words.map((w) => w[0]).join(''); } // Map a semester to the official date range label. function deriveDateRange(semester) { const s = String(semester || '').toUpperCase(); if (/1ST|FIRST/.test(s)) return 'AUGUST - JANUARY'; if (/2ND|SECOND/.test(s)) return 'JANUARY - MAY'; if (/SUMMER/.test(s)) return 'JUNE - JULY'; return ''; } // Load the bundled header logo (JPEG) in the extension background page. // Returns null in Node (tests) or if the asset is unavailable. async function loadLogo() { try { const url = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL) ? browser.runtime.getURL('assets/logo.jpeg') : null; if (!url) return null; const resp = await fetch(url); if (!resp.ok) return null; return new Uint8Array(await resp.arrayBuffer()); } catch (e) { return null; } } // Builds a Letter-size PDF replicating the official ISMIS Study Load layout: // header logo, title, semester + date range, date enrolled, a student info // row (ID, name, program, Year Level), the course table (GROUP, COURSE, // COURSE DESCRIPTION, TEACHER, UNITS, NOTE), and a footer (total under the // UNITS column, printed-by). NOTE is the status; schedule is stacked under // the course description. async function buildStudyLoadPdf(data) { const PDFLib = getPDFLib(); const { PDFDocument, StandardFonts, rgb } = PDFLib; const header = data.header || {}; const courses = data.courses || []; const doc = await PDFDocument.create(); let page = doc.addPage([612, 792]); // Letter const font = doc.embedStandardFont(StandardFonts.Helvetica); const bold = doc.embedStandardFont(StandardFonts.HelveticaBold); const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique); const margin = 36; const contentWidth = 539.3; // matches reference right edge 575.3 let y = 792 - 10; // near the very top, matching the reference logo position const black = rgb(0, 0, 0); const gray = rgb(0.3, 0.3, 0.3); const logoBytes = await loadLogo(); let logoImg = null; if (logoBytes) { try { logoImg = await doc.embedJpg(logoBytes); } catch (e) { logoImg = null; } } function center(text, size, f, dy) { const w = f.widthOfTextAtSize(text, size); page.drawText(text, { x: (612 - w) / 2, y, size, font: f, color: black }); y -= dy; } function left(text, size, f, x, dy) { page.drawText(text, { x, y, size, font: f, color: black }); y -= dy; } function hline(x1, x2, atY) { page.drawRectangle({ x: x1, y: atY, width: x2 - x1, height: 0.75, color: black }); } // ---- Header ---- if (logoImg) { const logoW = 292.5; const logoH = logoW * (logoImg.height / logoImg.width); page.drawImage(logoImg, { x: (612 - logoW) / 2, y: y - logoH, width: logoW, height: logoH }); y -= logoH + 8; } center("STUDENT'S STUDY LOAD", 9.5, bold, 14); if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.5); const dateRange = header.dateRange || deriveDateRange(header.semester); if (dateRange) center(dateRange, 9, font, 13.5); if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13.5); // Student info row, laid out in fixed zones across the page (matching the // reference): ID (bold) left, name at ~100, program at ~338, and // "Year Level: N" right-aligned to the right content edge (575.3). const yearLevelText = header.yearLevel ? 'Year Level: ' + header.yearLevel : ''; if (header.studentId) page.drawText(header.studentId, { x: margin, y, size: 9, font: bold, color: black }); if (header.fullName) page.drawText(formatFullName(header.fullName), { x: 100, y, size: 9, font, color: black }); if (header.program) page.drawText(compactProgram(header.program), { x: 338, y, size: 9, font, color: black }); if (yearLevelText) { const w = font.widthOfTextAtSize(yearLevelText, 9); page.drawText(yearLevelText, { x: margin + contentWidth - w, y, size: 9, font, color: black }); } if (header.studentId || header.fullName || header.program || yearLevelText) y -= 14.5; // ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ---- // Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3). const cols = [ { key: 'group', label: 'GROUP', x: 36.0, w: 28.8, align: 'left' }, { key: 'courseCode', label: 'COURSE', x: 64.8, w: 47.6, align: 'left' }, { key: 'description', label: 'COURSE DESCRIPTION', x: 112.4, w: 201.4, align: 'left' }, { key: 'teacher', label: 'TEACHER', x: 313.8, w: 127.6, align: 'left' }, { key: 'units', label: 'UNITS', x: 441.4, w: 29.5, align: 'right' }, { key: 'status', label: 'NOTE', x: 470.9, w: 104.4, align: 'right' } ]; const cellSize = 8; const headerSize = 8; const lineH = 12; // Header label positions tuned for Helvetica: left/center aligned within // each column so labels never overlap. function headerLabelXFor() { const widths = {}; for (const c of cols) { const key = c.label.toUpperCase().replace(/\s+/g, '_'); widths[key] = bold.widthOfTextAtSize(c.label, headerSize); } return { GROUP: cols[0].x, COURSE: cols[1].x + 5, COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2, TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2, UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2, NOTE: cols[5].x + (cols[5].w - widths.NOTE) / 2 }; } function rowLines(cells) { return cols.map((c) => { let lines; if (c.key === 'description') { lines = wrapLines(cells.description, font, cellSize, c.w - 6); const sched = String(cells.schedule || ''); if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6)); } else { lines = wrapLines(cells[c.key], font, cellSize, c.w - 6); } return lines; }); } function drawRow(cells, isHeader) { const f = isHeader ? bold : font; const size = isHeader ? headerSize : cellSize; const linesPerCol = rowLines(cells); if (isHeader) { // Reference header row: only a top and bottom horizontal rule; no // vertical dividers. Text baseline at y; top rule 1.8pt above the // text bbox top; bottom rule 11.5pt below the text bbox top. For // 8pt Helvetica the text bbox top sits ~8.6pt above the baseline, // so the lines are offset by +9.6 (top) and -3.7 (bottom) relative // to y. Data rows start 13pt below the header text bbox top. const topY = y + 9.6; const botY = y - 3.7; if (botY < 50) { page = doc.addPage([612, 792]); y = 792 - 10; return drawHeaderRow(); } hline(margin, margin + contentWidth, topY); const labelXs = headerLabelXFor(); for (let i = 0; i < cols.length; i++) { const c = cols[i]; const labelKey = c.label.toUpperCase().replace(/\s+/g, '_'); const lx = labelXs[labelKey]; const lines = linesPerCol[i]; page.drawText(lines[0], { x: lx, y, size, font: f, color: black }); } hline(margin, margin + contentWidth, botY); y -= 13.0; return true; } // Data rows: fixed 24.5pt baseline-to-baseline spacing. Description + // schedule stack with 12pt line spacing. Expand the row height when // the schedule or wrapped description needs more lines. const maxLines = Math.max(...linesPerCol.map((l) => l.length)); const rowHeight = Math.max(24.5, maxLines * lineH + 0.5); if (y - rowHeight < 50) { page = doc.addPage([612, 792]); y = 792 - 10; drawHeaderRow(); } for (let i = 0; i < cols.length; i++) { const c = cols[i]; const lines = linesPerCol[i]; let ty = y; for (const line of lines) { if (c.key === 'units') { const w = f.widthOfTextAtSize(line, size); page.drawText(line, { x: c.x + c.w - w, y: ty, size, font: f, color: black }); } else if (c.key === 'status') { page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black }); } else { let dx = 3; if (c.key === 'group') dx = 12; else if (c.key === 'courseCode') dx = 8; else if (c.key === 'teacher') dx = 3.5; else if (c.key === 'description') dx = 3.5; page.drawText(line, { x: c.x + dx, y: ty, size, font: f, color: black }); } ty -= lineH; } } y -= rowHeight; return true; } function drawHeaderRow() { const cells = {}; cols.forEach((c) => { cells[c.key] = c.label; }); drawRow(cells, true); } drawHeaderRow(); for (const course of courses) { const cells = { group: course.group, courseCode: course.courseCode, description: course.description, schedule: formatSchedule(course.schedule), teacher: course.teacher || course.faculty, units: course.units, status: course.status }; drawRow(cells, false); } // ---- Footer ---- if (y < 90) { page = doc.addPage([612, 792]); y = 792 - 10; } y -= 12; if (data.totalUnits) { // Total sits under the UNITS column, right-aligned within it. const unitsCol = cols[4]; const w = bold.widthOfTextAtSize(data.totalUnits, 8); // Short underline above the total, spanning the UNITS column. hline(unitsCol.x, unitsCol.x + unitsCol.w, y + 9); page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - w, y, size: 8, font: bold, color: black }); y -= 12; } hline(margin, margin + contentWidth, y); y -= 12; const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString(); const pw = font.widthOfTextAtSize(printedBy, 8); page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black }); return doc.save(); // returns Promise } return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName }; });