diff --git a/content.js b/content.js index 59c56b8..6bd3eb0 100644 --- a/content.js +++ b/content.js @@ -168,9 +168,10 @@ const included = ISMIStools.filterStudyLoadCourses(courses); if (!included.length) throw new Error('No enrolled courses found.'); const totalUnits = ISMIStools.sumUnits(included); + const { truncateDescription } = await browser.storage.local.get('truncateDescription'); const res = await browser.runtime.sendMessage({ type: 'generate-pdf', - data: { header, courses: included, totalUnits } + data: { header, courses: included, totalUnits, truncateDescription: truncateDescription !== false } }); if (!res || !res.ok) throw new Error((res && res.error) || 'PDF generation failed'); showToast('PDF downloaded.'); diff --git a/pdf.js b/pdf.js index 35232dd..127b5bd 100644 --- a/pdf.js +++ b/pdf.js @@ -64,6 +64,22 @@ 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: // "BACHELOR OF SCIENCE IN INFORMATION TECHNOLOGY" -> "BS IT". // Multi-word fields become initials; single-word fields stay title-cased. @@ -177,6 +193,9 @@ const { PDFDocument, StandardFonts, rgb } = PDFLib; const header = data.header || {}; 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(); let page = doc.addPage([612, 792]); // Letter @@ -278,7 +297,12 @@ return cols.map((c) => { let lines; 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 || ''); if (sched) lines.push(...wrapLines(sched, font, cellSize, c.w - 6)); } else { @@ -408,5 +432,5 @@ return doc.save(); // returns Promise } - return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName }; + return { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, truncateToWidth }; }); diff --git a/popup.html b/popup.html index 5d21125..f28c2f4 100644 --- a/popup.html +++ b/popup.html @@ -32,6 +32,11 @@ +
+

PDF Settings

+ +
+ diff --git a/popup.js b/popup.js index e7aae3a..5de4e9e 100644 --- a/popup.js +++ b/popup.js @@ -18,6 +18,15 @@ $('#masterToggle').addEventListener('change', async (e) => { $('#masterLabel').textContent = e.target.checked ? 'Enabled' : 'Disabled'; }); +// ---- PDF settings: truncate long course descriptions (default ON) ---- +async function loadTruncate() { + const { truncateDescription } = await browser.storage.local.get('truncateDescription'); + $('#truncateToggle').checked = truncateDescription !== false; +} +$('#truncateToggle').addEventListener('change', async (e) => { + await browser.storage.local.set({ truncateDescription: e.target.checked }); +}); + // ---- Profiles ---- async function getProfiles() { const { profiles } = await browser.storage.local.get(PROFILES_KEY); @@ -104,5 +113,6 @@ $('#saveProfile').addEventListener('click', async () => { // ---- Init ---- (async function init() { await loadMaster(); + await loadTruncate(); renderProfiles(await getProfiles()); })(); diff --git a/test/pdf.test.js b/test/pdf.test.js index 222a0e2..be95d7b 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, formatSchedule, formatFullName } = require('../pdf.js'); +const { buildStudyLoadPdf, compactProgram, deriveDateRange, formatSchedule, formatFullName, truncateToWidth } = require('../pdf.js'); const sampleData = { header: { @@ -88,3 +88,16 @@ test('deriveDateRange maps semesters', () => { assert.strictEqual(deriveDateRange('Summer 2026'), 'JUNE - JULY'); 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), ''); +});