PageSourceSearch

https://kogitlabs.com/js/pdf.js

js kogitlabs.com collected 2026-10-03 21:16:15 UTC 24,361 bytes, 565 lines download raw bytes

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.