Compare commits
7
Commits
2fa4064c7c
...
e11a7aad83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11a7aad83 | ||
|
|
cb47d6faac | ||
|
|
106900860a | ||
|
|
b3b4495427 | ||
|
|
79d58f994d | ||
|
|
161f540e40 | ||
|
|
79702cb847 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+14
-2
@@ -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.');
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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), '');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user