feat: 1:1 reference styling for Study Load PDF (header box, spacing, columns, schedule format, name format)
This commit is contained in:
@@ -29,7 +29,27 @@
|
|||||||
return lines.length ? lines : [''];
|
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) {
|
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) => {
|
||||||
@@ -102,7 +122,7 @@
|
|||||||
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
|
const bold = doc.embedStandardFont(StandardFonts.HelveticaBold);
|
||||||
const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
|
const italic = doc.embedStandardFont(StandardFonts.HelveticaOblique);
|
||||||
const margin = 36;
|
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
|
let y = 792 - 10; // near the very top, matching the reference logo position
|
||||||
|
|
||||||
const black = rgb(0, 0, 0);
|
const black = rgb(0, 0, 0);
|
||||||
@@ -132,41 +152,47 @@
|
|||||||
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 + 10;
|
y -= logoH + 8;
|
||||||
}
|
}
|
||||||
center("STUDENT'S STUDY LOAD", 9.5, bold, 16);
|
center("STUDENT'S STUDY LOAD", 9.5, bold, 14);
|
||||||
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 14);
|
if (header.semester) center(String(header.semester).toUpperCase(), 9, font, 13.5);
|
||||||
const dateRange = header.dateRange || deriveDateRange(header.semester);
|
const dateRange = header.dateRange || deriveDateRange(header.semester);
|
||||||
if (dateRange) center(dateRange, 9, font, 14);
|
if (dateRange) center(dateRange, 9, font, 13.5);
|
||||||
if (header.dateEnrolled) left('Date Enrolled: ' + header.dateEnrolled, 8, italic, margin, 13);
|
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
|
// 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 (576).
|
// "Year Level: N" right-aligned to the right content edge (575.3).
|
||||||
const yearLevelText = header.yearLevel ? 'Year Level: ' + header.yearLevel : '';
|
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.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 (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 });
|
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 ----
|
// ---- 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 = [
|
const cols = [
|
||||||
{ key: 'group', label: 'GROUP', x: margin, w: 38, align: 'left' },
|
{ key: 'group', label: 'GROUP', x: 36.0, w: 28.8, align: 'left' },
|
||||||
{ key: 'courseCode', label: 'COURSE', x: margin + 38, w: 42, align: 'left' },
|
{ key: 'courseCode', label: 'COURSE', x: 64.8, w: 47.6, align: 'left' },
|
||||||
{ key: 'description', label: 'COURSE DESCRIPTION', x: margin + 80, w: 201, align: 'left' },
|
{ key: 'description', label: 'COURSE DESCRIPTION', x: 112.4, w: 201.4, align: 'left' },
|
||||||
{ key: 'teacher', label: 'TEACHER', x: margin + 281, w: 128, align: 'left' },
|
{ key: 'teacher', label: 'TEACHER', x: 313.8, w: 127.6, align: 'left' },
|
||||||
{ key: 'units', label: 'UNITS', x: margin + 409, w: 31, align: 'right' },
|
{ key: 'units', label: 'UNITS', x: 441.4, w: 29.5, align: 'right' },
|
||||||
{ key: 'status', label: 'NOTE', x: margin + 440, w: 100, align: 'right' }
|
{ key: 'status', label: 'NOTE', x: 470.9, w: 104.4, align: 'right' }
|
||||||
];
|
];
|
||||||
const cellSize = 8;
|
const cellSize = 8;
|
||||||
const headerSize = 8;
|
const headerSize = 8;
|
||||||
const lineH = 12;
|
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) {
|
function rowLines(cells) {
|
||||||
return cols.map((c) => {
|
return cols.map((c) => {
|
||||||
let lines;
|
let lines;
|
||||||
@@ -185,38 +211,70 @@
|
|||||||
const f = isHeader ? bold : font;
|
const f = isHeader ? bold : font;
|
||||||
const size = isHeader ? headerSize : cellSize;
|
const size = isHeader ? headerSize : cellSize;
|
||||||
const linesPerCol = rowLines(cells);
|
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) {
|
if (y - rowHeight < 50) {
|
||||||
page = doc.addPage([612, 792]);
|
page = doc.addPage([612, 792]);
|
||||||
y = 792 - 10;
|
y = 792 - 10;
|
||||||
drawHeaderRow();
|
drawHeaderRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHeader) {
|
|
||||||
hline(margin, margin + contentWidth, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < cols.length; i++) {
|
for (let i = 0; i < cols.length; i++) {
|
||||||
const c = cols[i];
|
const c = cols[i];
|
||||||
const lines = linesPerCol[i];
|
const lines = linesPerCol[i];
|
||||||
let ty = y - 9;
|
let ty = y;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (c.align === 'right') {
|
if (c.key === 'units') {
|
||||||
const w = f.widthOfTextAtSize(line, size);
|
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 {
|
} 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;
|
ty -= lineH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
y -= rowHeight;
|
y -= rowHeight;
|
||||||
|
|
||||||
if (isHeader) {
|
|
||||||
hline(margin, margin + contentWidth, y);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +291,7 @@
|
|||||||
group: course.group,
|
group: course.group,
|
||||||
courseCode: course.courseCode,
|
courseCode: course.courseCode,
|
||||||
description: course.description,
|
description: course.description,
|
||||||
schedule: course.schedule,
|
schedule: formatSchedule(course.schedule),
|
||||||
teacher: course.teacher || course.faculty,
|
teacher: course.teacher || course.faculty,
|
||||||
units: course.units,
|
units: course.units,
|
||||||
status: course.status
|
status: course.status
|
||||||
@@ -246,22 +304,22 @@
|
|||||||
page = doc.addPage([612, 792]);
|
page = doc.addPage([612, 792]);
|
||||||
y = 792 - 10;
|
y = 792 - 10;
|
||||||
}
|
}
|
||||||
y -= 8;
|
y -= 12;
|
||||||
if (data.totalUnits) {
|
if (data.totalUnits) {
|
||||||
// Total sits under the UNITS column, right-aligned within it.
|
// Total sits under the UNITS column, right-aligned within it.
|
||||||
const unitsCol = cols[4];
|
const unitsCol = cols[4];
|
||||||
const w = bold.widthOfTextAtSize(data.totalUnits, 9);
|
const w = bold.widthOfTextAtSize(data.totalUnits, 8);
|
||||||
page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - 3 - w, y, size: 9, font: bold, color: black });
|
page.drawText(data.totalUnits, { x: unitsCol.x + unitsCol.w - w, y, size: 8, font: bold, color: black });
|
||||||
y -= 14;
|
y -= 12;
|
||||||
}
|
}
|
||||||
hline(margin, margin + contentWidth, y);
|
hline(margin, margin + contentWidth, y);
|
||||||
y -= 12;
|
y -= 12;
|
||||||
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: gray });
|
page.drawText(printedBy, { x: margin + contentWidth - pw, y, size: 8, font, color: black });
|
||||||
|
|
||||||
return doc.save(); // returns Promise<Uint8Array>
|
return doc.save(); // returns Promise<Uint8Array>
|
||||||
}
|
}
|
||||||
|
|
||||||
return { buildStudyLoadPdf, compactProgram, deriveDateRange };
|
return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName };
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-1
@@ -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 } = require('../pdf.js');
|
const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName } = require('../pdf.js');
|
||||||
|
|
||||||
const sampleData = {
|
const sampleData = {
|
||||||
header: {
|
header: {
|
||||||
@@ -61,6 +61,19 @@ test('compactProgram shortens degree names', () => {
|
|||||||
assert.strictEqual(compactProgram('UNKNOWN PROGRAM'), 'UNKNOWN PROGRAM');
|
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', () => {
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user