diff --git a/pdf.js b/pdf.js index 6c4e3c5..a395fe3 100644 --- a/pdf.js +++ b/pdf.js @@ -29,7 +29,27 @@ return lines.length ? lines : ['']; } - // Wraps multi-line text (segments split on \n) into wrapped lines. + // Format the raw schedule string to match the official PDF style: + // comma-separate day codes (M W -> M,W) and trim duplicated room suffixes. + function formatSchedule(schedule) { + let s = String(schedule || '').replace(/\s+/g, ' ').trim(); + if (!s) return s; + 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'); + s = s.replace(/TC(?:\s+TC)+$/g, 'TC'); + 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) => { @@ -102,7 +122,7 @@ const bold = doc.embedStandardFont(StandardFonts.HelveticaBold); const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique); const margin = 36; - const contentWidth = 612 - margin * 2; // 540; right edge = 576 + 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); @@ -132,41 +152,47 @@ 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 + 10; + y -= logoH + 8; } - center("STUDENT'S STUDY LOAD", 9.5, bold, 16); - if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 14); + 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, 14); - if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13); + 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 (576). + // "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(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 (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 -= 16; + if (header.studentId || header.fullName || header.program || yearLevelText) y -= 14.5; // ---- Table: GROUP, COURSE, COURSE DESCRIPTION, TEACHER, UNITS, NOTE ---- - // Column left edges + widths, tuned to the reference geometry (right edge 576). + // Column boundaries tuned to the reference PDF (left edge 36, right edge 575.3). const cols = [ - { key: 'group', label: 'GROUP', x: margin, w: 38, align: 'left' }, - { key: 'courseCode', label: 'COURSE', x: margin + 38, w: 42, align: 'left' }, - { key: 'description', label: 'COURSE DESCRIPTION', x: margin + 80, w: 201, align: 'left' }, - { key: 'teacher', label: 'TEACHER', x: margin + 281, w: 128, align: 'left' }, - { key: 'units', label: 'UNITS', x: margin + 409, w: 31, align: 'right' }, - { key: 'status', label: 'NOTE', x: margin + 440, w: 100, align: 'right' } + { 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; + // Reference header label x-positions (these do not all align with column + // left edges because the official PDF centers/offsets the labels). + const headerLabelX = { GROUP: 37.3, COURSE: 73.8, COURSE_DESCRIPTION: 172.9, TEACHER: 361.1, UNITS: 445.1, NOTE: 513.0 }; + // Vertical divider lines in the header row, matching the reference. + const headerVLines = [36.0, 98.3, 336.9, 470.9, 575.3]; + function rowLines(cells) { return cols.map((c) => { let lines; @@ -185,38 +211,70 @@ const f = isHeader ? bold : font; const size = isHeader ? headerSize : cellSize; const linesPerCol = rowLines(cells); - const rowHeight = Math.max(...linesPerCol.map((l) => l.length)) * lineH + 12; + if (isHeader) { + // Reference header row: top rule ~1.8pt above text baseline, bottom + // rule 11.5pt below text baseline. pdf-lib draws text baseline ~8.6pt + // above the passed y, while rectangles sit ~0.8pt below, so we offset + // the lines by +9.6 (top) and -3.7 (bottom) relative to y. + 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); + for (const vx of headerVLines) { + page.drawRectangle({ x: vx - 0.375, y: botY, width: 0.75, height: topY - botY, color: black }); + } + + for (let i = 0; i < cols.length; i++) { + const c = cols[i]; + const labelKey = c.key === 'description' ? 'COURSE_DESCRIPTION' : c.key.toUpperCase(); + const lx = headerLabelX[labelKey] != null ? headerLabelX[labelKey] : c.x + 3; + const lines = linesPerCol[i]; + page.drawText(lines[0], { x: lx, y, size, font: f, color: black }); + } + + hline(margin, margin + contentWidth, botY); + y -= 4.4; + return true; + } + + // Data rows: fixed 24.5pt baseline-to-baseline spacing. Description + + // schedule stack with 12pt line spacing. + const rowHeight = 24.5; if (y - rowHeight < 50) { page = doc.addPage([612, 792]); y = 792 - 10; drawHeaderRow(); } - if (isHeader) { - hline(margin, margin + contentWidth, y); - } - for (let i = 0; i < cols.length; i++) { const c = cols[i]; const lines = linesPerCol[i]; - let ty = y - 9; + let ty = y; for (const line of lines) { - if (c.align === 'right') { + if (c.key === 'units') { const w = f.widthOfTextAtSize(line, size); - page.drawText(line, { x: c.x + c.w - 3 - w, y: ty, size, font: f, color: black }); + 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 { - page.drawText(line, { x: c.x + 3, y: ty, size, font: f, color: black }); + 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; - - if (isHeader) { - hline(margin, margin + contentWidth, y); - } return true; } @@ -233,7 +291,7 @@ group: course.group, courseCode: course.courseCode, description: course.description, - schedule: course.schedule, + schedule: formatSchedule(course.schedule), teacher: course.teacher || course.faculty, units: course.units, status: course.status @@ -246,22 +304,22 @@ page = doc.addPage([612, 792]); y = 792 - 10; } - y -= 8; + 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, 9); - page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - 3 - w, y, size: 9, font: bold, color: black }); - y -= 14; + const w = bold.widthOfTextAtSize(data.totalUnits, 8); + 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: gray }); + page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black }); return doc.save(); // returns Promise } - return { buildStudyLoadPdf, compactProgram, deriveDateRange }; + return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName }; }); diff --git a/test/pdf.test.js b/test/pdf.test.js index 468c556..e2ff615 100644 --- a/test/pdf.test.js +++ b/test/pdf.test.js @@ -5,7 +5,7 @@ const os = require('node:os'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); global.PDFLib = require('pdf-lib'); -const { buildStudyLoadPdf, compactProgram, deriveDateRange } = require('../pdf.js'); +const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName } = require('../pdf.js'); const sampleData = { header: { @@ -61,6 +61,19 @@ test('compactProgram shortens degree names', () => { assert.strictEqual(compactProgram('UNKNOWN PROGRAM'), 'UNKNOWN PROGRAM'); }); +test('formatSchedule normalizes schedule text', () => { + assert.strictEqual(formatSchedule('M W 07:30 AM - 10:00 AM LB447TC TC'), 'M,W 07:30 AM - 10:00 AM LB447TC'); + assert.strictEqual(formatSchedule('T Th 09:00 AM - 10:30 AM LB443TC TC'), 'T,Th 09:00 AM - 10:30 AM LB443TC'); + assert.strictEqual(formatSchedule('M,W 03:00 PM - 05:30 PM LB443TC'), 'M,W 03:00 PM - 05:30 PM LB443TC'); +}); + +test('formatFullName reorders first-last names to last-first', () => { + assert.strictEqual(formatFullName('ADRIAN ALFRED CHIONG BONPIN'), 'BONPIN, ADRIAN ALFRED CHIONG'); + assert.strictEqual(formatFullName('BONPIN, ADRIAN ALFRED CHIONG'), 'BONPIN, ADRIAN ALFRED CHIONG'); + assert.strictEqual(formatFullName('JOHN DOE'), 'DOE, JOHN'); + assert.strictEqual(formatFullName(''), ''); +}); + test('deriveDateRange maps semesters', () => { assert.strictEqual(deriveDateRange('1ST Semester 2026'), 'AUGUST - JANUARY'); assert.strictEqual(deriveDateRange('2ND Semester 2026'), 'JANUARY - MAY');