1/* ============================================================ 2 KogIT Labs: dependency-free PDF writer. 3 4 Produces real, spec-valid PDF 1.4 files entirely in the browser 5 (no CDN, no build step) so every "Download" on the site hands the 6 educator an actual .pdf rather than a text dump. 7 8 Public API: 9 KogitPDF.fromMaterial(doc, opts) -> Blob (structured doc from materials-content.js) 10 KogitPDF.fromSlides(title, urls) -> Promise<Blob> (image deck, one slide per page) 11 KogitPDF.forResource(resource) -> Promise<Blob> (dispatches on a data.js catalog entry) 12 KogitPDF.save(blob, filename) 13 KogitPDF.download(resource) -> Promise<boolean> 14 ============================================================ */ 15(function () { 16 "use strict"; 17 18 /* ---------- page geometry (US Letter, portrait) ---------- */ 19 var PAGE_W = 612, PAGE_H = 792; 20 var MARGIN = 56; 21 var CONTENT_W = PAGE_W - MARGIN * 2; 22 var FOOTER_H = 44; 23 24 /* ---------- palette (matches the site) ---------- */ 25 var C = { 26 ink: "#20231f", body: "#565e56", muted: "#8a938d", 27 accent: "#2f7d5b", accentSoft: "#eaf6ef", line: "#e3e7e1", 28 tableHead: "#f3f5f1", white: "#ffffff" 29 }; 30 31 /* ---------- Adobe base-14 AFM widths (chars 32..126, /1000 em) ---------- */ 32 var W_REG = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278, 33 556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015, 34 667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611, 35 278,278,278,469,556,333, 36 556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500, 37 334,260,334,584]; 38 var W_BOLD = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278, 39 556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975, 40 722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611, 41 333,278,333,584,556,333, 42 556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500, 43 389,280,389,584]; 44 45 /* ---------- text encoding: JS string -> WinAnsi byte codes ---------- */ 46 // Base-14 fonts use WinAnsiEncoding. Map the typographic characters the site 47 // actually uses; anything else outside the table degrades to a safe ASCII form. 48 var WIN = { 0x2013: 0x96, 0x2014: 0x97, 0x2018: 0x91, 0x2019: 0x92, 0x201C: 0x93, 49 0x201D: 0x94, 0x2022: 0x95, 0x2026: 0x85, 0x2020: 0x86, 0x2122: 0x99, 0x20AC: 0x80 }; 50 var ASCIIFY = { 0x2192: "->", 0x2190: "<-", 0x2713: "*", 0x2714: "*", 0x00A0: " " }; 51 52 function toWinAnsi(str) { 53 var out = []; 54 str = String(str == null ? "" : str); 55 for (var i = 0; i < str.length; i++) { 56 var cp = str.charCodeAt(i); 57 if (ASCIIFY[cp]) { var s = ASCIIFY[cp]; for (var k = 0; k < s.length; k++) out.push(s.charCodeAt(k)); continue; } 58 if (WIN[cp] != null) { out.push(WIN[cp]); continue; } 59 if (cp <= 0xFF) { out.push(cp); continue; } 60 out.push(0x3F); // '?' 61 } 62 return out; 63 } 64 65 // PDF literal string: escape \ ( ) and emit octal for high bytes. 66 function pdfString(str) { 67 var bytes = toWinAnsi(str), out = "("; 68 for (var i = 0; i < bytes.length; i++) { 69 var b = bytes[i]; 70 if (b === 0x28 || b === 0x29 || b === 0x5C) out += "\\" + String.fromCharCode(b); 71 else if (b < 32 || b > 126) out += "\\" + ("00" + b.toString(8)).slice(-3); 72 else out += String.fromCharCode(b); 73 } 74 return out + ")"; 75 } 76 77 function widthOf(str, size, bold) { 78 var arr = bold ? W_BOLD : W_REG, bytes = toWinAnsi(str), w = 0; 79 for (var i = 0; i < bytes.length; i++) { 80 var c = bytes[i]; 81 w += (c >= 32 && c <= 126) ? arr[c - 32] : 556; 82 } 83 return w * size / 1000; 84 } 85 86 function wrapText(str, size, bold, maxW) { 87 var words = String(str == null ? "" : str).split(/\s+/).filter(Boolean); 88 var lines = [], line = ""; 89 for (var i = 0; i < words.length; i++) { 90 var test = line ? line + " " + words[i] : words[i]; 91 if (line && widthOf(test, size, bold) > maxW) { lines.push(line); line = words[i]; } 92 else line = test; 93 } 94 if (line) lines.push(line); 95 return lines.length ? lines : [""]; 96 } 97 98 function rgb(hex) { 99 var h = hex.replace("#", "");
100 var r = parseInt(h.slice(0, 2), 16) / 255, g = parseInt(h.slice(2, 4), 16) / 255, b = parseInt(h.slice(4, 6), 16) / 255; 101 return r.toFixed(3) + " " + g.toFixed(3) + " " + b.toFixed(3); 102 } 103 104 /* ============================================================ 105 Doc: a paginating layout surface 106 ============================================================ */ 107 function Doc(meta) { 108 this.meta = meta || {}; 109 this.pages = []; 110 this.ops = null; 111 this.y = 0; 112 this.addPage(); 113 } 114 Doc.prototype.addPage = function () { 115 this.ops = []; 116 this.pages.push(this.ops); 117 this.y = PAGE_H - MARGIN; 118 }; 119 Doc.prototype.need = function (h) { 120 if (this.y - h < MARGIN + FOOTER_H) { this.addPage(); return true; } 121 return false; 122 }; 123 Doc.prototype.op = function (s) { this.ops.push(s); }; 124 125 Doc.prototype.text = function (str, opts) { 126 opts = opts || {}; 127 var size = opts.size || 10.5; 128 var bold = !!opts.bold; 129 var color = opts.color || C.body; 130 var x = opts.x == null ? MARGIN : opts.x; 131 var maxW = opts.maxW || (MARGIN + CONTENT_W - x); 132 var lead = opts.lead || size * 1.5; 133 var lines = opts.noWrap ? [String(str)] : wrapText(str, size, bold, maxW); 134 for (var i = 0; i < lines.length; i++) { 135 this.need(lead); 136 this.op("BT " + rgb(color) + " rg /" + (bold ? "F2" : "F1") + " " + size + " Tf" + 137 (opts.charSpace ? " " + opts.charSpace + " Tc" : "") + 138 " 1 0 0 1 " + x.toFixed(2) + " " + (this.y - size).toFixed(2) + " Tm " + pdfString(lines[i]) + " Tj ET"); 139 this.y -= lead; 140 } 141 return lines.length; 142 }; 143 144 Doc.prototype.rect = function (x, y, w, h, fill) { 145 this.op(rgb(fill) + " rg " + x.toFixed(2) + " " + y.toFixed(2) + " " + w.toFixed(2) + " " + h.toFixed(2) + " re f"); 146 }; 147 Doc.prototype.hline = function (y, color, width, x1, x2) { 148 this.op(rgb(color || C.line) + " RG " + (width || 0.8) + " w " + 149 (x1 == null ? MARGIN : x1).toFixed(2) + " " + y.toFixed(2) + " m " + 150 (x2 == null ? MARGIN + CONTENT_W : x2).toFixed(2) + " " + y.toFixed(2) + " l S"); 151 }; 152 Doc.prototype.dot = function (x, y, r, color) { 153 // small filled square reads as a bullet at 3px and needs no curve math 154 this.rect(x - r, y - r, r * 2, r * 2, color || C.accent); 155 }; 156 Doc.prototype.space = function (n) { this.y -= n; }; 157 158 /* ---------- block helpers ---------- */ 159 Doc.prototype.cover = function (title, meta, summary) { 160 this.text("KogIT Labs · Classroom materials", { size: 8.5, bold: true, color: C.accent, charSpace: 1.1, lead: 16 }); 161 this.space(6); 162 this.text(title, { size: 23, bold: true, color: C.ink, lead: 28 }); 163 this.space(2); 164 if (meta) this.text(meta, { size: 9.5, color: C.muted, lead: 15 }); 165 this.space(8); 166 this.hline(this.y, C.accent, 1.6, MARGIN, MARGIN + 54); 167 this.space(16); 168 if (summary) { this.text(summary, { size: 11, color: C.body, lead: 16.5 }); this.space(10); } 169 }; 170 171 Doc.prototype.heading = function (str) { 172 // Keep-with-next: reserve the heading AND room for the first few lines of 173 // whatever follows it. Reserving only the heading let a section title sit 174 // alone at the foot of a page with its content overleaf: 15 of them across 175 // the answer keys. 46 covers the rule and spacing, 80 the first item. 176 // Measured: clears all 15 orphans at zero extra pages. 177 this.need(46 + 80); 178 this.space(10); 179 this.text(str, { size: 13, bold: true, color: C.ink, lead: 18 }); 180 this.hline(this.y + 4, C.line, 0.8); 181 this.space(8); 182 }; 183 184 Doc.prototype.para = function (str) { 185 this.text(str, { size: 10.5, color: C.body, lead: 15.5 }); 186 this.space(6); 187 }; 188 189 Doc.prototype.bullets = function (items) { 190 var self = this; 191 items.forEach(function (li) { 192 var lines = wrapText(li, 10.5, false, CONTENT_W - 18); 193 self.need(lines.length * 15.5); 194 self.dot(MARGIN + 3.5, self.y - 6, 1.8, C.accent); 195 lines.forEach(function (ln) { 196 self.op("BT " + rgb(C.body) + " rg /F1 10.5 Tf 1 0 0 1 " + (MARGIN + 16).toFixed(2) + " " + (self.y - 10.5).toFixed(2) + " Tm " + pdfString(ln) + " Tj ET"); 197 self.y -= 15.5; 198 }); 199 self.space(3); 200 }); 201 this.space(4); 202 }; 203 204 Doc.prototype.steps = function (items) { 205 var self = this;
206 items.forEach(function (it, i) { 207 var titleLines = wrapText(it.t, 10.5, true, CONTENT_W - 32); 208 var descLines = it.d ? wrapText(it.d, 10, false, CONTENT_W - 32) : []; 209 self.need(titleLines.length * 14 + descLines.length * 13.5 + 8); 210 var top = self.y; 211 // number chip 212 self.rect(MARGIN, top - 14, 16, 16, C.accentSoft); 213 self.op("BT " + rgb(C.accent) + " rg /F2 9 Tf 1 0 0 1 " + (MARGIN + (i + 1 > 9 ? 3 : 5.5)) + " " + (top - 10.5).toFixed(2) + " Tm " + pdfString(String(i + 1)) + " Tj ET"); 214 titleLines.forEach(function (ln) { 215 self.op("BT " + rgb(C.ink) + " rg /F2 10.5 Tf 1 0 0 1 " + (MARGIN + 26).toFixed(2) + " " + (self.y - 10.5).toFixed(2) + " Tm " + pdfString(ln) + " Tj ET"); 216 self.y -= 14; 217 }); 218 descLines.forEach(function (ln) { 219 self.op("BT " + rgb(C.body) + " rg /F1 10 Tf 1 0 0 1 " + (MARGIN + 26).toFixed(2) + " " + (self.y - 10).toFixed(2) + " Tm " + pdfString(ln) + " Tj ET"); 220 self.y -= 13.5; 221 }); 222 self.space(7); 223 }); 224 this.space(2); 225 }; 226 227 Doc.prototype.cards = function (items) { 228 var self = this; 229 var gap = 12, colW = (CONTENT_W - gap) / 2; 230 for (var i = 0; i < items.length; i += 2) { 231 var pair = items.slice(i, i + 2); 232 // measure the tallest of the pair so both boxes match 233 var h = 0; 234 pair.forEach(function (cd) { 235 var tl = wrapText(cd.t, 10, true, colW - 20).length; 236 var dl = wrapText(cd.d, 9.5, false, colW - 20).length; 237 h = Math.max(h, 14 + tl * 13 + dl * 12.5 + 10); 238 }); 239 self.need(h + 10); 240 var top = self.y; 241 pair.forEach(function (cd, j) { 242 var x = MARGIN + j * (colW + gap); 243 self.rect(x, top - h, colW, h, C.accentSoft); 244 var yy = top - 14; 245 wrapText(cd.t, 10, true, colW - 20).forEach(function (ln) { 246 self.op("BT " + rgb(C.ink) + " rg /F2 10 Tf 1 0 0 1 " + (x + 10).toFixed(2) + " " + yy.toFixed(2) + " Tm " + pdfString(ln) + " Tj ET"); 247 yy -= 13; 248 }); 249 yy -= 2; 250 wrapText(cd.d, 9.5, false, colW - 20).forEach(function (ln) { 251 self.op("BT " + rgb(C.body) + " rg /F1 9.5 Tf 1 0 0 1 " + (x + 10).toFixed(2) + " " + yy.toFixed(2) + " Tm " + pdfString(ln) + " Tj ET"); 252 yy -= 12.5; 253 }); 254 }); 255 self.y = top - h - 10; 256 } 257 this.space(2); 258 }; 259 260 Doc.prototype.table = function (cols, rows) { 261 var self = this; 262 var n = cols.length; 263 // first column carries the label and gets the extra room (mirrors the web tables) 264 var units = cols.map(function (_, i) { return i === 0 ? 2 : 1; }); 265 var total = units.reduce(function (a, b) { return a + b; }, 0); 266 var widths = units.map(function (u) { return CONTENT_W * u / total; }); 267 var padX = 8, padY = 7; 268 269 function rowHeight(cells, size, bold) { 270 var h = 0; 271 cells.forEach(function (v, i) { 272 h = Math.max(h, wrapText(v, size, bold && i === 0, widths[i] - padX * 2).length * 12.5); 273 }); 274 return h + padY * 2; 275 } 276 function drawRow(cells, opts) { 277 var size = opts.size, h = rowHeight(cells, size, opts.boldFirst); 278 self.need(h); 279 var top = self.y; 280 if (opts.fill) self.rect(MARGIN, top - h, CONTENT_W, h, opts.fill); 281 if (opts.rule) self.hline(top, C.line, 0.6); 282 var x = MARGIN; 283 cells.forEach(function (v, i) { 284 var bold = opts.head || (opts.boldFirst && i === 0); 285 var color = opts.head ? C.muted : (i === 0 ? C.ink : C.body); 286 var yy = top - padY - size; 287 wrapText(v, size, bold, widths[i] - padX * 2).forEach(function (ln) { 288 self.op("BT " + rgb(color) + " rg /" + (bold ? "F2" : "F1") + " " + size + " Tf" + 289 (opts.head ? " 0.6 Tc" : "") + 290 " 1 0 0 1 " + (x + padX).toFixed(2) + " " + yy.toFixed(2) + " Tm " + pdfString(ln) + " Tj ET"); 291 yy -= 12.5; 292 }); 293 x += widths[i]; 294 }); 295 self.y = top - h; 296 } 297 298 this.need(60); 299 drawRow(cols.map(function (c) { return String(c).toUpperCase(); }), { size: 8, head: true, fill: C.tableHead }); 300 rows.forEach(function (r, i) { 301 drawRow(r.map(function (v) { return String(v); }), { size: 9.5, boldFirst: true, rule: true }); 302 }); 303 this.hline(this.y, C.line, 0.6); 304 this.space(12); 305 }; 306 307 /* ---------- footer stamped on every page at finalize ---------- */ 308 function stampFooters(doc, label) { 309 var total = doc.pages.length; 310 for (var i = 0; i < total; i++) { 311 var ops = doc.pages[i]; 312 var y = MARGIN + 22; 313 ops.push(rgb(C.line) + " RG 0.8 w " + MARGIN + " " + (y + 12) + " m " + (MARGIN + CONTENT_W) + " " + (y + 12) + " l S"); 314 ops.push("BT " + rgb(C.muted) + " rg /F1 8 Tf 1 0 0 1 " + MARGIN + " " + y + " Tm " + pdfString(label) + " Tj ET"); 315 var pn = "Page " + (i + 1) + " of " + total; 316 var w = widthOf(pn, 8, false); 317 ops.push("BT " + rgb(C.muted) + " rg /F1 8 Tf 1 0 0 1 " + (MARGIN + CONTENT_W - w).toFixed(2) + " " + y + " Tm " + pdfString(pn) + " Tj ET"); 318 } 319 } 320 321 /* ============================================================
322 Serializer 323 ============================================================ */ 324 function bytesOfAscii(str) { 325 var out = new Uint8Array(str.length); 326 for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xFF; 327 return out; 328 } 329 330 // objects: array of { str } or { str, bin, tail } 331 function serialize(objects, trailerRootId, infoId) { 332 var chunks = [], offset = 0, xref = []; 333 function push(part) { 334 var b = typeof part === "string" ? bytesOfAscii(part) : part; 335 chunks.push(b); offset += b.length; 336 } 337 push("%PDF-1.4\n"); 338 push(new Uint8Array([0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A])); // binary marker 339 for (var i = 0; i < objects.length; i++) { 340 xref.push(offset); 341 var o = objects[i]; 342 push((i + 1) + " 0 obj\n" + o.str + "\n"); 343 if (o.bin) { push(o.bin); push(o.tail || "\nendstream\n"); } 344 push("endobj\n"); 345 } 346 var startxref = offset; 347 var x = "xref\n0 " + (objects.length + 1) + "\n0000000000 65535 f \n"; 348 for (var j = 0; j < xref.length; j++) x += ("0000000000" + xref[j]).slice(-10) + " 00000 n \n"; 349 x += "trailer\n<< /Size " + (objects.length + 1) + " /Root " + trailerRootId + " 0 R" + 350 (infoId ? " /Info " + infoId + " 0 R" : "") + " >>\nstartxref\n" + startxref + "\n%%EOF\n"; 351 push(x); 352 return new Blob(chunks, { type: "application/pdf" }); 353 } 354 355 function buildPdf(pageStreams, pageBoxes, extra) { 356 extra = extra || {}; 357 var objs = []; 358 function add(o) { objs.push(typeof o === "string" ? { str: o } : o); return objs.length; } 359 360 var fontReg = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); 361 var fontBold = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"); 362 363 var imageIds = []; 364 (extra.images || []).forEach(function (img) { 365 var id = add({ 366 str: "<< /Type /XObject /Subtype /Image /Width " + img.w + " /Height " + img.h + 367 " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + img.bytes.length + " >>\nstream\n", 368 bin: img.bytes, tail: "\nendstream\n" 369 }); 370 imageIds.push(id); 371 }); 372 373 var pagesId = objs.length + 1 + pageStreams.length * 2 + 1; // placeholder, fixed below 374 var pageIds = [], contentIds = []; 375 // content streams first 376 pageStreams.forEach(function (s) { 377 contentIds.push(add("<< /Length " + s.length + " >>\nstream\n" + s + "\nendstream")); 378 }); 379 // reserve the Pages object id so Page /Parent can point at it 380 var pagesObjIndex = objs.length; // 0-based slot we will fill in a moment 381 objs.push({ str: "" }); // placeholder 382 pagesId = pagesObjIndex + 1; 383 384 pageStreams.forEach(function (s, i) { 385 var box = pageBoxes[i] || { w: PAGE_W, h: PAGE_H }; 386 var res = "<< /Font << /F1 " + fontReg + " 0 R /F2 " + fontBold + " 0 R >>"; 387 if (imageIds.length) { 388 res += " /XObject << " + imageIds.map(function (id, k) { return "/Im" + k + " " + id + " 0 R"; }).join(" ") + " >>"; 389 } 390 res += " >>"; 391 pageIds.push(add("<< /Type /Page /Parent " + pagesId + " 0 R /MediaBox [0 0 " + box.w + " " + box.h + "]" + 392 " /Resources " + res + " /Contents " + contentIds[i] + " 0 R >>")); 393 }); 394 395 objs[pagesObjIndex] = { str: "<< /Type /Pages /Count " + pageIds.length + " /Kids [" + pageIds.map(function (id) { return id + " 0 R"; }).join(" ") + "] >>" }; 396 397 var catalogId = add("<< /Type /Catalog /Pages " + pagesId + " 0 R >>"); 398 var infoId = add("<< /Title " + pdfString(extra.title || "KogIT Labs") + 399 " /Author (KogIT Labs) /Creator (KogIT Labs portal) /Producer (KogIT Labs) >>"); 400 401 return serialize(objs, catalogId, infoId); 402 } 403 404 /* ============================================================ 405 Public: structured material -> PDF 406 ============================================================ */ 407 function fromMaterial(doc, opts) { 408 opts = opts || {}; 409 var d = new Doc(); 410 d.cover(doc.title, (doc.type ? doc.type + " · " : "") + (doc.meta || ""), doc.summary); 411
412 (doc.sections || []).forEach(function (sec) { 413 if (sec.h) d.heading(sec.h); 414 if (sec.type === "text") d.para(sec.body); 415 else if (sec.type === "list") d.bullets(sec.items || []); 416 else if (sec.type === "steps") d.steps(sec.items || []); 417 else if (sec.type === "cards") d.cards(sec.items || []); 418 else if (sec.type === "table") d.table(sec.cols || [], sec.rows || []); 419 }); 420 421 stampFooters(d, "KogIT Labs · " + doc.title); 422 return buildPdf(d.pages.map(function (ops) { return ops.join("\n"); }), 423 d.pages.map(function () { return { w: PAGE_W, h: PAGE_H }; }), 424 { title: doc.title }); 425 } 426 427 /* ============================================================ 428 Public: image deck -> PDF (one slide per page) 429 Slides are re-encoded to JPEG via canvas so they embed as 430 DCTDecode, which needs no filter/predictor gymnastics. 431 ============================================================ */ 432 function loadImage(src) { 433 return new Promise(function (resolve, reject) { 434 var im = new Image(); 435 im.crossOrigin = "anonymous"; 436 im.onload = function () { resolve(im); }; 437 im.onerror = function () { reject(new Error("could not load " + src)); }; 438 im.src = src; 439 }); 440 } 441 function jpegBytes(img, quality) { 442 var cv = document.createElement("canvas"); 443 cv.width = img.naturalWidth; cv.height = img.naturalHeight; 444 var ctx = cv.getContext("2d"); 445 ctx.fillStyle = "#ffffff"; 446 ctx.fillRect(0, 0, cv.width, cv.height); // flatten any alpha onto white 447 ctx.drawImage(img, 0, 0); 448 var data = cv.toDataURL("image/jpeg", quality || 0.86).split(",")[1]; 449 var bin = atob(data), out = new Uint8Array(bin.length); 450 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 451 return { bytes: out, w: cv.width, h: cv.height }; 452 } 453 454 function fromSlides(title, urls) { 455 return Promise.all(urls.map(loadImage)).then(function (imgs) { 456 var images = imgs.map(function (im) { 457 var j = jpegBytes(im); 458 return { bytes: j.bytes, w: j.w, h: j.h }; 459 }); 460 var streams = [], boxes = []; 461 images.forEach(function (img, i) { 462 // page matches the slide's aspect, capped to a comfortable on-screen width 463 var pw = 720, ph = Math.round(pw * img.h / img.w); 464 var pad = 18; 465 var iw = pw - pad * 2, ih = ph - pad * 2; 466 var ops = []; 467 ops.push(rgb(C.white) + " rg 0 0 " + pw + " " + ph + " re f"); 468 ops.push("q " + iw + " 0 0 " + ih + " " + pad + " " + pad + " cm /Im" + i + " Do Q"); 469 streams.push(ops.join("\n")); 470 boxes.push({ w: pw, h: ph }); 471 }); 472 return buildPdf(streams, boxes, { images: images, title: title }); 473 }); 474 } 475 476 /* ============================================================ 477 Dispatch on a data.js catalog resource 478 ============================================================ */ 479 function generate(resource) { 480 if (resource.deck) return fromSlides(resource.title, KogitData.CLOUD_DECK_SLIDES); 481 var content = window.KogitMaterials && KogitMaterials[resource.contentKey]; 482 if (content) return Promise.resolve(fromMaterial(content)); 483 return Promise.resolve(null); 484 } 485 function forResource(resource) { return generate(resource); } 486 487 function slug(s) { 488 return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); 489 } 490 491 function save(blob, filename) { 492 var a = document.createElement("a"); 493 a.href = URL.createObjectURL(blob); 494 a.download = filename; 495 document.body.appendChild(a); a.click(); 496 setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 500); 497 } 498 499 /* One call the pages use: hands the user a real .pdf for a catalog resource. 500 Prefers a shipped file, but only if it is actually present: several of the 501 originals exceed the design API's transfer cap and aren't on disk yet, so we 502 probe first and fall back to generating rather than downloading a 404 page. 503 Resolves true when a file was delivered. */ 504 function download(resource) { 505 // Single chokepoint for every download path (materials list, preview modal,
506 // resources page), so both gates live here rather than in each caller. 507 // Resolves the string "locked" so callers stay quiet instead of reporting 508 // success. 509 // 510 // (1) Guests can browse and preview materials in-browser, but downloading 511 // the file requires a signed-in account: sign in or request free educator 512 // access. This drives sign-ups the same way the locked library does. 513 if (window.KogitStore && !KogitStore.currentUser()) { 514 var name = (window.KogitUI && KogitUI.esc) ? KogitUI.esc(resource.title) : resource.title; 515 if (window.KogitUI && KogitUI.signInPrompt) { 516 KogitUI.signInPrompt("Log in to download <strong>" + name + "</strong>, or request free educator access."); 517 } 518 return Promise.resolve("locked"); 519 } 520 // (1b) Answer keys are educator/admin only, never students (and not demo/ 521 // sandbox educators). The Worker enforces this on the file itself too 522 // (gate.js answerKeyGate); this is the in-UI stop with a clear message. 523 if (resource.answerKey && window.KogitStore) { 524 var cu = KogitStore.currentUser(); 525 var okRole = cu && (cu.role === "admin" || (cu.role === "educator" && !cu.user.demo)); 526 if (!okRole) { 527 if (window.KogitUI && KogitUI.toast) KogitUI.toast("Answer keys are available to educator accounts only."); 528 return Promise.resolve("locked"); 529 } 530 } 531 // (2) Demo accounts can open only their short allowed list. 532 if (window.KogitStore && KogitStore.isResourceLocked(resource.id)) { 533 KogitUI.lockedPrompt(resource.title); 534 return Promise.resolve("locked"); 535 } 536 function direct() { 537 var a = document.createElement("a"); 538 a.href = resource.file; a.download = ""; 539 document.body.appendChild(a); a.click(); a.remove(); 540 return true; 541 } 542 function gen() { 543 return generate(resource).then(function (blob) { 544 if (!blob) return false; 545 save(blob, "kogit-" + slug(resource.title) + ".pdf"); 546 return true; 547 }); 548 } 549 if (resource.file) { 550 return fetch(resource.file, { method: "HEAD" }) 551 .then(function (r) { return r.ok ? direct() : gen(); }) 552 .catch(gen); // file:// or offline: generating is the safe path 553 } 554 return gen(); 555 } 556 557 window.KogitPDF = { 558 fromMaterial: fromMaterial, 559 fromSlides: fromSlides, 560 forResource: forResource, 561 download: download, 562 save: save, 563 slug: slug 564 }; 565})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.