7 Commits
13 changed files with 246 additions and 39 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+14 -2
View File
@@ -164,13 +164,25 @@
try { try {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...'; btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Downloading...';
const { header, courses } = await scrapeStudyLoad(); const { header, courses, period: selectedPeriod } = await scrapeStudyLoad();
const included = ISMIStools.filterStudyLoadCourses(courses); const included = ISMIStools.filterStudyLoadCourses(courses);
if (!included.length) throw new Error('No enrolled courses found.'); if (!included.length) throw new Error('No enrolled courses found.');
const totalUnits = ISMIStools.sumUnits(included); const totalUnits = ISMIStools.sumUnits(included);
const settings = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
// Resolve the Year Level: custom value wins; otherwise prefer the scraped
// value, smart-adjusted (minus one) during SUMMER when the year level has
// not yet advanced.
header.showYearLevel = settings.showYearLevel !== false;
const custom = String(settings.customYearLevel || '').trim();
if (custom) {
header.yearLevel = custom;
} else if (/summer/i.test(selectedPeriod || '')) {
const n = parseInt(header.yearLevel, 10);
if (Number.isFinite(n) && n > 1) header.yearLevel = String(n - 1);
}
const res = await browser.runtime.sendMessage({ const res = await browser.runtime.sendMessage({
type: 'generate-pdf', type: 'generate-pdf',
data: { header, courses: included, totalUnits } data: { header, courses: included, totalUnits, truncateDescription: settings.truncateDescription !== false }
}); });
if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed'); if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed');
showToast('PDF downloaded.'); showToast('PDF downloaded.');
+1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
"permissions": ["storage", "downloads"], "permissions": ["storage", "downloads"],
"host_permissions": ["https://ismis.usc.edu.ph/*"], "host_permissions": ["https://ismis.usc.edu.ph/*"],
"background": { "background": {
"scripts": ["libs/pdf-lib.min.js", "pdf.js", "background.js"] "scripts": ["libs/fontkit.min.js", "libs/pdf-lib.min.js", "pdf.js", "background.js"]
}, },
"content_scripts": [ "content_scripts": [
{ {
+172 -35
View File
@@ -56,6 +56,44 @@
const last = parts.pop(); const last = parts.pop();
return last + ', ' + parts.join(' '); return last + ', ' + parts.join(' ');
} }
// Format a faculty/teacher name to match the official PDF's style:
// "First [MiddleInitial] Last" (no comma). Handles a source like
// "Surname, First Middle" by dropping full middle names while keeping
// honorifics/titles (e.g. FR.) and single-letter middle initials.
const HONORIFICS = new Set(['FR', 'REV', 'DR', 'SR', 'BR', 'PROF', 'ENGR', 'ATTY', 'HON', 'MR', 'MS', 'MRS', 'BRO', 'SIS']);
function formatTeacher(name) {
let v = String(name || '').trim();
if (!v) return v;
let surname = '', given = [];
if (/,/.test(v)) {
const idx = v.lastIndexOf(',');
surname = v.slice(0, idx).trim();
given = v.slice(idx + 1).trim().split(/\s+/).filter(Boolean);
} else {
const tok = v.split(/\s+/).filter(Boolean);
if (tok.length === 0) return v;
if (tok.length === 1) return v;
given = tok.slice(0, -1);
surname = tok[tok.length - 1];
}
const out = [];
let seenFirstName = false;
for (const tok of given) {
const bare = tok.replace(/\.$/, '').toUpperCase();
const isTitle = HONORIFICS.has(bare);
const isInitial = tok.length === 1 || (bare.length === 1 && /\.$/.test(tok));
if (isTitle || isInitial) {
out.push(tok);
} else if (!seenFirstName) {
out.push(tok);
seenFirstName = true;
}
// full middle names (after the first name) are dropped
}
if (surname) out.push(surname);
return out.join(' ');
}
function wrapLines(text, font, size, maxWidth) { function wrapLines(text, font, size, maxWidth) {
const out = []; const out = [];
String(text == null ? '' : text).split(/\n/).forEach((seg) => { String(text == null ? '' : text).split(/\n/).forEach((seg) => {
@@ -64,6 +102,22 @@
return out.length ? out : ['']; return out.length ? out : [''];
} }
// Truncate a string to the longest prefix that fits within maxWidth at the
// given size, hard-cutting mid-word with no ellipsis. Mirrors the official
// PDF which cuts off course descriptions that exceed the column width.
function truncateToWidth(text, font, size, maxWidth) {
const s = String(text == null ? '' : text).trim();
if (!s) return '';
if (font.widthOfTextAtSize(s, size) <= maxWidth) return s;
let lo = 0, hi = s.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (font.widthOfTextAtSize(s.slice(0, mid), size) <= maxWidth) lo = mid;
else hi = mid - 1;
}
return s.slice(0, lo);
}
// Compact a degree name for display: // Compact a degree name for display:
// "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT". // "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT".
// Multi-word fields become initials; single-word fields stay title-cased. // Multi-word fields become initials; single-word fields stay title-cased.
@@ -94,13 +148,12 @@
return ''; return '';
} }
// Load the bundled header logo (JPEG) in the extension background page. // Fetch a bundled asset in the extension background page. Returns null in
// Returns null in Node (tests) or if the asset is unavailable. // Node (tests) or if the asset is unavailable.
async function loadLogo() { async function loadAssetBytes(asset) {
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
try { try {
const url = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL) const url = ext ? browser.runtime.getURL(asset) : null;
? browser.runtime.getURL('assets/logo.jpeg')
: null;
if (!url) return null; if (!url) return null;
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) return null; if (!resp.ok) return null;
@@ -110,6 +163,63 @@
} }
} }
// Embed the bundled Segoe UI fonts (regular/bold/italic). Fontkit is
// required by pdf-lib to embed custom TTF fonts. Falls back to Helvetica
// (standard fonts) when the assets are unavailable (e.g. Node tests).
async function loadFonts(doc, PDFLib) {
const { StandardFonts } = PDFLib;
const fontkit = (typeof self !== 'undefined' && self.fontkit) ||
(typeof globalThis !== 'undefined' && globalThis.fontkit) ||
null;
const segoe = {
regular: 'assets/fonts/Segoe-UI.ttf',
bold: 'assets/fonts/Segoe-UI-Bold.ttf',
italic: 'assets/fonts/Segoe-UI-Italic.ttf'
};
try {
if (fontkit) {
doc.registerFontkit(fontkit);
const regular = await loadAssetBytes(segoe.regular);
const bold = await loadAssetBytes(segoe.bold);
const italic = await loadAssetBytes(segoe.italic);
if (regular && bold && italic) {
return {
font: await doc.embedFont(regular),
bold: await doc.embedFont(bold),
italic: await doc.embedFont(italic),
isCustom: true
};
}
}
} catch (e) { /* fall through to Helvetica */ }
return {
font: doc.embedStandardFont(StandardFonts.Helvetica),
bold: doc.embedStandardFont(StandardFonts.HelveticaBold),
italic: doc.embedStandardFont(StandardFonts.HelveticaOblique),
isCustom: false
};
}
// Load the bundled header logo. Prefer the transparent PNG (matches the
// official PDF, which masks the JPEG's baked-in background) for a clean
// look on white; fall back to the JPEG in Node (tests) or if unavailable.
async function loadLogo() {
const ext = (typeof browser !== 'undefined' && browser.runtime && browser.runtime.getURL);
const candidates = ['assets/logo.png', 'assets/logo.jpeg'];
for (const asset of candidates) {
try {
const url = ext ? browser.runtime.getURL(asset) : null;
if (!url) return null;
const resp = await fetch(url);
if (!resp.ok) continue;
return { bytes: new Uint8Array(await resp.arrayBuffer()), transparent: /logo\.png$/.test(asset) };
} catch (e) {
continue;
}
}
return null;
}
// Builds a Letter-size PDF replicating the official ISMIS Study Load layout: // Builds a Letter-size PDF replicating the official ISMIS Study Load layout:
// header logo, title, semester + date range, date enrolled, a student info // header logo, title, semester + date range, date enrolled, a student info
// row (ID, name, program, Year Level), the course table (GROUP, COURSE, // row (ID, name, program, Year Level), the course table (GROUP, COURSE,
@@ -121,12 +231,14 @@
const { PDFDocument, StandardFonts, rgb } = PDFLib; const { PDFDocument, StandardFonts, rgb } = PDFLib;
const header = data.header || {}; const header = data.header || {};
const courses = data.courses || []; const courses = data.courses || [];
// Truncate long course descriptions to a single line matching the official
// PDF (default ON). Disabling allows descriptions to wrap.
const truncateDescription = data.truncateDescription !== false;
const doc = await PDFDocument.create(); const doc = await PDFDocument.create();
let page = doc.addPage([612, 792]); // Letter let page = doc.addPage([612, 792]); // Letter
const font = doc.embedStandardFont(StandardFonts.Helvetica); const fonts = await loadFonts(doc, PDFLib);
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold); const { font, bold, italic } = fonts;
const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
const margin = 36; const margin = 36;
const contentWidth = 539.3; // matches reference right edge 575.3 const contentWidth = 539.3; // matches reference right edge 575.3
let y = 792 - 10; // near the very top, matching the reference logo position let y = 792 - 10; // near the very top, matching the reference logo position
@@ -134,10 +246,16 @@
const black = rgb(0, 0, 0); const black = rgb(0, 0, 0);
const gray = rgb(0.3, 0.3, 0.3); const gray = rgb(0.3, 0.3, 0.3);
const logoBytes = await loadLogo(); const logo = await loadLogo();
let logoImg = null; let logoImg = null;
if (logoBytes) { if (logo) {
try { logoImg = await doc.embedJpg(logoBytes); } catch (e) { logoImg = null; } try {
// Transparent PNG embeds with an alpha mask (matches official PDF);
// the JPEG is embedded as-is (no mask).
logoImg = logo.transparent
? await doc.embedPng(logo.bytes)
: await doc.embedJpg(logo.bytes);
} catch (e) { logoImg = null; }
} }
function center(text, size, f, dy) { function center(text, size, f, dy) {
@@ -158,26 +276,38 @@
const logoW = 292.5; const logoW = 292.5;
const logoH = logoW * (logoImg.height / logoImg.width); const logoH = logoW * (logoImg.height / logoImg.width);
page.drawImage(logoImg, { x: (612 - logoW) / 2, y: y - logoH, width: logoW, height: logoH }); page.drawImage(logoImg, { x: (612 - logoW) / 2, y: y - logoH, width: logoW, height: logoH });
y -= logoH + 8; y -= logoH + 8.79;
} }
center("STUDENT'S STUDY LOAD", 9.5, bold, 14); // Baseline advances match the reference (Segoe UI) header layout.
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.5); center("STUDENT'S STUDY LOAD", 9.5, bold, 13.84);
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.76);
const dateRange = header.dateRange || deriveDateRange(header.semester); const dateRange = header.dateRange || deriveDateRange(header.semester);
if (dateRange) center(dateRange, 9, font, 13.5); if (dateRange) center(dateRange, 9, font, 11.75);
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13.5); // Always reserve the Date Enrolled line's vertical space (so the layout
// stays aligned with the reference) even though we never have the value.
if (header.dateEnrolled) {
left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 14.53);
} else {
y -= 14.53; // keep the same gap, drawing nothing
}
// Student info row, laid out in fixed zones across the page (matching the // Student info row, laid out in fixed zones across the page (matching the
// reference): ID (bold) left, name at ~100, program at ~338, and // reference): ID (bold) left, name at ~100, program at ~338, and
// "Year Level: N" right-aligned to the right content edge (575.3). // "Year Level: N" right-aligned to the right content edge (575.3).
const yearLevelText = header.yearLevel ? 'Year Level: ' + header.yearLevel : ''; // Year Level is shown by default; the extension can hide it or override the
// value via settings (header.showYearLevel / header.yearLevel).
const yearLevelText = (header.showYearLevel !== false && header.yearLevel)
? 'Year Level: ' + header.yearLevel
: '';
if (header.studentId) page.drawText(header.studentId, { x: margin, y, size: 9, font: bold, color: black }); 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.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 (header.program) page.drawText(compactProgram(header.program), { x: 338, y, size: 9, font, color: black });
if (yearLevelText) { if (yearLevelText) {
const w = font.widthOfTextAtSize(yearLevelText, 9); const w = font.widthOfTextAtSize(yearLevelText, 9);
page.drawText(yearLevelText, { x: margin + contentWidth - w, y, size: 9, font, color: black }); // Right-aligned to the content edge, nudged left ~6pt (half of 1rem).
page.drawText(yearLevelText, { x: margin + contentWidth - w - 6, y, size: 9, font, color: black });
} }
if (header.studentId || header.fullName || header.program || yearLevelText) y -= 14.5; if (header.studentId || header.fullName || header.program || yearLevelText) y -= 13.59;
// ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ---- // ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ----
// Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3). // Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3).
@@ -193,17 +323,17 @@
const headerSize = 8; const headerSize = 8;
const lineH = 12; const lineH = 12;
// Header label positions tuned for Helvetica: left/center aligned within // Header label positions tuned for Segoe UI (matches the reference):
// each column so labels never overlap. // left/center aligned within each column so labels never overlap.
function headerLabelXFor() { function headerLabelXFor() {
const widths = {}; const widths = {};
for (const c of cols) { for (const c of cols) {
const key = c.label.toUpperCase().replace(/\s+/g, '_'); const key = c.label.toUpperCase().replace(/\s+/g, '_');
widths[key] = bold.widthOfTextAtSize(c.label, headerSize); widths[key] = font.widthOfTextAtSize(c.label, headerSize);
} }
return { return {
GROUP: cols[0].x, GROUP: cols[0].x + 1.3,
COURSE: cols[1].x + 5, COURSE: cols[1].x + 9,
COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2, COURSE_DESCRIPTION: cols[2].x + (cols[2].w - widths.COURSE_DESCRIPTION) / 2,
TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2, TEACHER: cols[3].x + (cols[3].w - widths.TEACHER) / 2,
UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2, UNITS: cols[4].x + (cols[4].w - widths.UNITS) / 2,
@@ -215,7 +345,12 @@
return cols.map((c) => { return cols.map((c) => {
let lines; let lines;
if (c.key === 'description') { if (c.key === 'description') {
lines = wrapLines(cells.description, font, cellSize, c.w - 6); if (truncateDescription) {
// Single line, hard-truncated to fit the column (official PDF style).
lines = [truncateToWidth(cells.description, font, cellSize, c.w - 3)];
} else {
lines = wrapLines(cells.description, font, cellSize, c.w - 6);
}
const sched = String(cells.schedule || ''); const sched = String(cells.schedule || '');
if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6)); if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6));
} else { } else {
@@ -226,7 +361,8 @@
} }
function drawRow(cells, isHeader) { function drawRow(cells, isHeader) {
const f = isHeader ? bold : font; // Header labels and data all use the body font (header is not bold).
const f = font;
const size = isHeader ? headerSize : cellSize; const size = isHeader ? headerSize : cellSize;
const linesPerCol = rowLines(cells); const linesPerCol = rowLines(cells);
@@ -278,8 +414,9 @@
let ty = y; let ty = y;
for (const line of lines) { for (const line of lines) {
if (c.key === 'units') { if (c.key === 'units') {
// Center unit values under the UNITS header, matching the reference.
const w = f.widthOfTextAtSize(line, size); const w = f.widthOfTextAtSize(line, size);
page.drawText(line, { x: c.x + c.w - w, y: ty, size, font: f, color: black }); page.drawText(line, { x: c.x + (c.w - w) / 2, y: ty, size, font: f, color: black });
} else if (c.key === 'status') { } else if (c.key === 'status') {
page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black }); page.drawText(line, { x: c.x + 35, y: ty, size, font: f, color: black });
} else { } else {
@@ -312,7 +449,7 @@
courseCode: course.courseCode, courseCode: course.courseCode,
description: course.description, description: course.description,
schedule: formatSchedule(course.schedule), schedule: formatSchedule(course.schedule),
teacher: course.teacher || course.faculty, teacher: formatTeacher(course.teacher || course.faculty),
units: course.units, units: course.units,
status: course.status status: course.status
}; };
@@ -324,18 +461,18 @@
page = doc.addPage([612, 792]); page = doc.addPage([612, 792]);
y = 792 - 10; y = 792 - 10;
} }
y -= 12;
if (data.totalUnits) { if (data.totalUnits) {
// Total sits under the UNITS column, right-aligned within it. // Total sits one standard row height below the last course row (matching
// the reference, which has a ~24.5pt gap), centered within the UNITS col.
const unitsCol = cols[4]; const unitsCol = cols[4];
const w = bold.widthOfTextAtSize(data.totalUnits, 8); const w = font.widthOfTextAtSize(data.totalUnits, 8);
// Short underline above the total, spanning the UNITS column. // Short underline above the total, spanning the UNITS column.
hline(unitsCol.x, unitsCol.x + unitsCol.w, y + 9); 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 }); page.drawText(data.totalUnits, { x: unitsCol.x + (unitsCol.w - w) / 2, y, size: 8, font, color: black });
y -= 12; y -= 12;
} }
hline(margin, margin + contentWidth, y); hline(margin, margin + contentWidth, y);
y -= 12; y -= 10.1;
const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString(); const printedBy = 'Printed by: ' + (header.studentId || '') + ' on ' + new Date().toLocaleDateString();
const pw = font.widthOfTextAtSize(printedBy, 8); const pw = font.widthOfTextAtSize(printedBy, 8);
page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black }); page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black });
@@ -343,5 +480,5 @@
return doc.save(); // returns Promise<Uint8Array> return doc.save(); // returns Promise<Uint8Array>
} }
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName }; return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth };
}); });
+6
View File
@@ -48,3 +48,9 @@ body {
border: 1px solid #ccc; border-radius: 4px; border: 1px solid #ccc; border-radius: 4px;
} }
.form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } .form .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.tab .row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.tab .row input[type="text"] {
flex: 1; min-width: 0; padding: 4px 6px;
border: 1px solid #ccc; border-radius: 4px;
}
.hint { font-size: 11px; color: #777; margin-top: 4px; }
+10
View File
@@ -32,6 +32,16 @@
</div> </div>
</section> </section>
<section class="tab">
<h2>PDF Settings</h2>
<label class="row"><input type="checkbox" id="truncateToggle"> Truncate long course descriptions (official PDF style)</label>
<label class="row"><input type="checkbox" id="showYearToggle"> Show Year Level on the PDF</label>
<label class="row">Custom Year Level (optional):
<input type="text" id="customYear" placeholder="e.g. 3">
</label>
<p class="hint">Leave blank to use the scraped value (auto-adjusted in Summer).</p>
</section>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
+18
View File
@@ -18,6 +18,23 @@ $('#masterToggle').addEventListener('change', async (e) => {
$('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled'; $('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled';
}); });
// ---- PDF settings: truncate long course descriptions (default ON) ----
async function loadPdfSettings() {
const s = await browser.storage.local.get(['truncateDescription', 'showYearLevel', 'customYearLevel']);
$('#truncateToggle').checked = s.truncateDescription !== false;
$('#showYearToggle').checked = s.showYearLevel !== false;
$('#customYear').value = s.customYearLevel || '';
}
$('#truncateToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ truncateDescription: e.target.checked });
});
$('#showYearToggle').addEventListener('change', async (e) => {
await browser.storage.local.set({ showYearLevel: e.target.checked });
});
$('#customYear').addEventListener('change', async (e) => {
await browser.storage.local.set({ customYearLevel: e.target.value.trim() });
});
// ---- Profiles ---- // ---- Profiles ----
async function getProfiles() { async function getProfiles() {
const { profiles } = await browser.storage.local.get(PROFILES_KEY); const { profiles } = await browser.storage.local.get(PROFILES_KEY);
@@ -104,5 +121,6 @@ $('#saveProfile').addEventListener('click', async () => {
// ---- Init ---- // ---- Init ----
(async function init() { (async function init() {
await loadMaster(); await loadMaster();
await loadPdfSettings();
renderProfiles(await getProfiles()); renderProfiles(await getProfiles());
})(); })();
+24 -1
View File
@@ -5,7 +5,7 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { execFileSync } = require('node:child_process'); const { execFileSync } = require('node:child_process');
global.PDFLib = require('pdf-lib'); global.PDFLib = require('pdf-lib');
const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName } = require('../pdf.js'); const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, formatTeacher, truncateToWidth } = require('../pdf.js');
const sampleData = { const sampleData = {
header: { header: {
@@ -82,9 +82,32 @@ test('formatFullName reorders first-last names to last-first', () => {
assert.strictEqual(formatFullName(''), ''); assert.strictEqual(formatFullName(''), '');
}); });
test('formatTeacher renders names First [MiddleInitial] Last like the reference', () => {
assert.strictEqual(formatTeacher('FILLO, FR. PETER'), 'FR. PETER FILLO');
assert.strictEqual(formatTeacher('BACALSO, REUELLA J. AWAYAN'), 'REUELLA J. BACALSO');
assert.strictEqual(formatTeacher('PEPITO, GLENN BULLECER'), 'GLENN PEPITO');
assert.strictEqual(formatTeacher('MADERAZO, CHRISTIAN'), 'CHRISTIAN MADERAZO');
assert.strictEqual(formatTeacher('DELA PAZ, KHENT'), 'KHENT DELA PAZ');
assert.strictEqual(formatTeacher('CHRISTIAN MADERAZO'), 'CHRISTIAN MADERAZO');
assert.strictEqual(formatTeacher(''), '');
});
test('deriveDateRange maps semesters', () => { test('deriveDateRange maps semesters', () => {
assert.strictEqual(deriveDateRange('1ST Semester 2026'), 'AUGUST - JANUARY'); assert.strictEqual(deriveDateRange('1ST Semester 2026'), 'AUGUST - JANUARY');
assert.strictEqual(deriveDateRange('2ND Semester 2026'), 'JANUARY - MAY'); assert.strictEqual(deriveDateRange('2ND Semester 2026'), 'JANUARY - MAY');
assert.strictEqual(deriveDateRange('Summer 2026'), 'JUNE - JULY'); assert.strictEqual(deriveDateRange('Summer 2026'), 'JUNE - JULY');
assert.strictEqual(deriveDateRange(''), ''); assert.strictEqual(deriveDateRange(''), '');
}); });
test('truncateToWidth hard-cuts text to fit within maxWidth', async () => {
const doc = await global.PDFLib.PDFDocument.create();
const font = doc.embedStandardFont(global.PDFLib.StandardFonts.Helvetica);
const longText = 'APPLICATION DEVELOPMENT AND EMERGING TECHNOLOGIES';
const tr = truncateToWidth(longText, font, 8, 100);
assert.ok(tr.length < longText.length, 'long text should be cut down');
assert.ok(longText.startsWith(tr), 'result must be a prefix of the input');
assert.ok(font.widthOfTextAtSize(tr, 8) <= 100, 'truncated line must fit maxWidth');
const short = 'DATA STRUCTURES';
assert.strictEqual(truncateToWidth(short, font, 8, 200), short);
assert.strictEqual(truncateToWidth('', font, 8, 200), '');
});