flyer.js (19.2 KB)
1 // Print-only flyer for the selected event. Most browsers get a print-CSS DOM flyer + 2 // window.print; Safari stamps a non-removable header/footer on printed web content, so 3 // there the flyer is drawn into a vector PDF (pdf.js) and opened in a new tab. 4 5 (function () { 6 var SVG_NS = "http://www.w3.org/2000/svg"; 7 var TAB_COUNT = 10; 8 var QUIET = 0; // no baked-in quiet zone 9 10 // Safari (desktop/iOS) but not the other browsers whose UA also carries "Safari". 11 // Safari takes the PDF path; the print CSS swap stays gated off (.flyer-print). 12 var isSafari = /Safari/.test(navigator.userAgent) && 13 !/Chrome|Chromium|CriOS|Edg|Android/.test(navigator.userAgent); 14 if (!isSafari) { document.documentElement.classList.add("flyer-print"); } 15 16 var flyerEl = null; 17 var building = false; // guards against the link + beforeprint paths double-building 18 19 function el(tag, cls, text) { 20 var n = document.createElement(tag); 21 if (cls) { n.className = cls; } 22 if (text != null) { n.textContent = text; } 23 return n; 24 } 25 26 // QR for `text` as an <svg> `mm` square. Dark modules merge into per-row run rects. 27 function qrSvg(text, mm) { 28 var matrix = window.qr.createMatrix(text); 29 var n = matrix.length; 30 var total = n + QUIET * 2; 31 32 var svg = document.createElementNS(SVG_NS, "svg"); 33 svg.setAttribute("viewBox", "0 0 " + total + " " + total); 34 svg.setAttribute("width", mm + "mm"); 35 svg.setAttribute("height", mm + "mm"); 36 svg.setAttribute("shape-rendering", "crispEdges"); 37 svg.classList.add("flyer-qr"); 38 39 var bg = document.createElementNS(SVG_NS, "rect"); 40 bg.setAttribute("width", total); 41 bg.setAttribute("height", total); 42 bg.setAttribute("fill", "#fff"); 43 svg.appendChild(bg); 44 45 for (var r = 0; r < n; r++) { 46 var c = 0; 47 while (c < n) { 48 if (!matrix[r][c]) { c++; continue; } 49 var start = c; 50 while (c < n && matrix[r][c]) { c++; } 51 var rect = document.createElementNS(SVG_NS, "rect"); 52 rect.setAttribute("x", start + QUIET); 53 rect.setAttribute("y", r + QUIET); 54 rect.setAttribute("width", c - start); 55 rect.setAttribute("height", 1); 56 rect.setAttribute("fill", "#000"); 57 svg.appendChild(rect); 58 } 59 } 60 return svg; 61 } 62 63 // .flyer-flow (the fitFlow-sized element) holds a floated QR the description wraps around. 64 function buildBody(m) { 65 var body = el("div", "flyer-body"); 66 body.appendChild(el("h1", "flyer-title", m.title)); 67 68 var flow = el("div", "flyer-flow"); 69 flow.appendChild(qrSvg(window.fmt.eventUrl(m), QR_BODY)); // max-size; fitQr shrinks to the line grid 70 flow.appendChild(el("p", "flyer-desc", m.description)); 71 var ul = el("ul", "flyer-details"); 72 window.fmt.detailItems(m).forEach(function (line) { 73 ul.appendChild(el("li", null, line)); 74 }); 75 flow.appendChild(ul); 76 body.appendChild(flow); 77 78 return body; 79 } 80 81 function buildTab(m) { 82 var inner = el("div", "flyer-tab-inner"); 83 var text = el("div", "flyer-tab-text"); 84 text.appendChild(el("div", "flyer-tab-title", m.title)); 85 text.appendChild(el("div", "flyer-tab-when", window.fmt.startLine(m))); 86 text.appendChild(el("div", "flyer-tab-where", m.venue)); 87 inner.appendChild(text); 88 inner.appendChild(qrSvg(window.fmt.eventUrl(m), 16)); 89 return inner; 90 } 91 92 function buildTabs(m) { 93 var tabs = el("div", "flyer-tabs"); 94 var template = buildTab(m); 95 for (var i = 0; i < TAB_COUNT; i++) { 96 var tab = el("div", "flyer-tab"); 97 tab.appendChild(template.cloneNode(true)); 98 tabs.appendChild(tab); 99 } 100 return tabs; 101 } 102 103 // Tabs are identical: fit the first's text and copy the font-size to the rest. 104 function fitTabs(tabsEl) { 105 var inners = tabsEl.querySelectorAll(".flyer-tab-inner"); 106 if (!inners.length) { return; } 107 var inner = inners[0]; 108 var text = inner.querySelector(".flyer-tab-text"); 109 // transforms don't affect layout: the rotated tab lays out in its unrotated frame 110 var maxH = inner.clientHeight; 111 var lo = 6, hi = 16; 112 while (lo < hi) { 113 var mid = Math.ceil((lo + hi) / 2); 114 inner.style.fontSize = mid + "pt"; 115 if (text.offsetHeight <= maxH) { lo = mid; } else { hi = mid - 1; } 116 } 117 var size = lo + "pt"; 118 for (var i = 0; i < inners.length; i++) { inners[i].style.fontSize = size; } 119 } 120 121 // White leading inside a line box, in em: Times metrics (ascent .683, descent .217, 122 // caps .662) with CSS half-leading. Page gaps are equalized as ink, so each margin 123 // is widened by the leading of the line boxes facing it. 124 function leadBelow(lineHeight) { return (lineHeight - 0.9) / 2 + 0.217; } 125 function leadAbove(lineHeight) { return (lineHeight - 0.9) / 2 + 0.683 - 0.662; } 126 127 // Largest QR (<= 42mm) whose float band ends on the flow's line grid (0.2mm short so 128 // rounding can't re-narrow the boundary line), so the first full-width line clears 129 // the float at the same 7mm gutter the text gets. Text size beats QR size. 130 function alignedQrMm(lineHmm) { 131 var n = Math.floor((2 + QR_BODY + 7) / lineHmm); 132 return n * lineHmm - (2 + 7) - 0.2; 133 } 134 135 // Run after fitFlow: shrinking the float only frees space, so the fitted size still fits. 136 function fitQr(flow) { 137 var qr = flow.querySelector(".flyer-qr"); 138 var size = alignedQrMm(parseFloat(flow.style.fontSize) * 1.3 * 25.4 / 96); // CSS px -> mm 139 qr.setAttribute("width", size + "mm"); 140 qr.setAttribute("height", size + "mm"); 141 } 142 143 // Largest quarter-px font-size at which the flow fits maxH. The flow is a stretched 144 // flex child, so collapse the stretch (flex:none, height:auto) to measure content. 145 function fitFlow(flow, maxH) { 146 flow.style.flex = "none"; 147 flow.style.height = "auto"; 148 flow.style.lineHeight = "1.3"; 149 var lo = 32, hi = 256; // quarter-px units 150 while (lo < hi) { 151 var mid = Math.ceil((lo + hi) / 2); 152 flow.style.fontSize = mid / 4 + "px"; 153 if (flow.getBoundingClientRect().height <= maxH) { lo = mid; } else { hi = mid - 1; } 154 } 155 flow.style.fontSize = lo / 4 + "px"; 156 flow.style.flex = ""; 157 flow.style.height = ""; 158 } 159 160 // Build the flyer for `m`, size its body text to fit, open the print dialog. Lays out 161 // offscreen at the print width first so measurements reflect true print layout. 162 function build(m) { 163 flyerEl = document.getElementById("flyer"); 164 if (!flyerEl) { return; } 165 flyerEl.textContent = ""; 166 167 var body = buildBody(m); 168 var tabs = buildTabs(m); 169 flyerEl.appendChild(body); 170 flyerEl.appendChild(tabs); 171 172 flyerEl.classList.add("flyer-measuring"); 173 var flow = body.querySelector(".flyer-flow"); 174 var title = body.querySelector(".flyer-title"); 175 var titleStyle = getComputedStyle(title); 176 // avail height from the rendered title block, not the flex-stretched flow box 177 var titleBlock = title.getBoundingClientRect().height + 178 parseFloat(titleStyle.marginTop) + parseFloat(titleStyle.marginBottom); 179 var flyerH = flyerEl.getBoundingClientRect().height; 180 var tabsH = tabs.getBoundingClientRect().height; 181 var maxH = flyerH - tabsH - titleBlock; 182 fitFlow(flow, Math.max(maxH, 0)); 183 184 // Equalize the three page gaps as ink (see leadBelow/leadAbove): comp is the net 185 // height the widened margins cost beyond 7mm; if positive, refit with it reserved. 186 var titlePx = parseFloat(titleStyle.fontSize); 187 function gapComp(f) { return (leadBelow(1.3) + leadAbove(1.3)) * f - leadBelow(1.05) * titlePx; } 188 var fs = parseFloat(flow.style.fontSize); 189 var comp = gapComp(fs); 190 if (comp > 0) { 191 fitFlow(flow, Math.max(maxH - comp, 0)); 192 fs = parseFloat(flow.style.fontSize); 193 comp = gapComp(fs); 194 } 195 fitQr(flow); 196 197 // leftover slack split a third per gap; measured after fitQr frees wrapped lines 198 flow.style.flex = "none"; 199 flow.style.height = "auto"; 200 var s3 = Math.max(maxH - comp - flow.getBoundingClientRect().height, 0) / 3; 201 flow.style.flex = ""; 202 flow.style.height = ""; 203 var b7 = 7 * 96 / 25.4; // 7mm base in px 204 title.style.marginBottom = (b7 + s3 + leadBelow(1.3) * fs - leadBelow(1.05) * titlePx) + "px"; 205 var ul = flow.querySelector(".flyer-details"); 206 ul.style.marginTop = (b7 + s3) + "px"; 207 ul.style.marginBottom = (b7 + s3 + leadAbove(1.3) * fs) + "px"; 208 209 fitTabs(tabs); 210 211 flyerEl.classList.remove("flyer-measuring"); 212 } 213 214 // ---- PDF path (Safari) ---- 215 // Mirrors the DOM flyer's CSS in flyer-local mm with a top-left origin; px()/py() 216 // are the only crossing into PDF points (origin bottom-left, y-up). 217 218 var MM = 72 / 25.4; // mm -> pt 219 var PAGE_W = 215.9, PAGE_H = 279.4; // US Letter 220 var BOX_W = 197.3, BOX_H = 266.7; // flyer box (shared Letter/A4 safe area) 221 var BOX_X = (PAGE_W - BOX_W) / 2, BOX_Y = 6.35; // centered, 6.35mm top margin 222 var TABS_H = 78, TAB_W = BOX_W / TAB_COUNT; 223 var DASH = { width: 0.75, dash: [2.25, 2.25] }; // 1px dashed at print scale 224 var TITLE_PT = 48, TITLE_LINE_H = TITLE_PT * 1.05 / MM; 225 var QR_BODY = 42; // mm, body QR max; alignedQrMm shrinks it to the line grid 226 var QR_TAB = 16; // mm, tab QR 227 var TAB_PAD_V = 2.5, TAB_PAD_TEXT = 1.5, TAB_PAD_QR = 2.5, TAB_GAP = 2.5; 228 var TAB_TEXT_W = TABS_H - TAB_PAD_TEXT - TAB_PAD_QR - TAB_GAP - QR_TAB; 229 var TAB_CONTENT_H = TAB_W - 2 * TAB_PAD_V; // cross-axis space (clientHeight in fitTabs) 230 231 function px(x) { return (BOX_X + x) * MM; } 232 function py(y) { return (PAGE_H - (BOX_Y + y)) * MM; } 233 234 // mm width of str at sizePt, via the vendored AFM tables 235 function tw(str, font, sizePt) { return window.pdf.widthOf(str, font, sizePt) / MM; } 236 237 // baseline's mm offset from the top of a line box (CSS half-leading, Times metrics) 238 function baselineOff(sizePt, lineHmm) { 239 return ((lineHmm * MM - sizePt * 0.9) / 2 + sizePt * 0.683) / MM; 240 } 241 242 // Word-wrap str into [{ text, yTop }], yTop in mm from y0. availFn(yTop) gives the 243 // usable width at a line's top (how the floated QR narrows lines beside it). Words 244 // wider than a line break mid-word (overflow-wrap: break-word). 245 function wrap(str, font, sizePt, availFn, y0, lineHmm) { 246 var lines = []; 247 var cur = ""; 248 var y = y0; 249 function push(s) { lines.push({ text: s, yTop: y }); y += lineHmm; } 250 String(str).split(/\s+/).filter(Boolean).forEach(function (word) { 251 var cand = cur ? cur + " " + word : word; 252 if (tw(cand, font, sizePt) <= availFn(y)) { cur = cand; return; } 253 if (cur) { push(cur); cur = ""; } 254 while (tw(word, font, sizePt) > availFn(y)) { 255 var k = 1; // longest fitting prefix; min 1 char so we always advance 256 while (k < word.length && tw(word.slice(0, k + 1), font, sizePt) <= availFn(y)) { k++; } 257 push(word.slice(0, k)); 258 word = word.slice(k); 259 } 260 cur = word; 261 }); 262 if (cur) { push(cur); } 263 return lines; 264 } 265 266 // title block: lines at 48pt/1.05 plus the h1's 1.5mm top / 7mm bottom margins 267 function layoutTitle(m) { 268 var lines = wrap(m.title, "times", TITLE_PT, function () { return BOX_W; }, 1.5, TITLE_LINE_H); 269 return { lines: lines, block: 1.5 + lines.length * TITLE_LINE_H + 7 }; 270 } 271 272 // Mirrors buildBody/fitFlow in flow-local mm: lines beside the floated QR wrap at the 273 // narrowed width; the flow is at least the float band tall. 274 function layoutFlow(m, pxSize, qrMm, gapTopMm, gapBotMm) { 275 var size = pxSize * 0.75; // CSS px -> pt 276 var lineH = size * 1.3 / MM; 277 var em = size / MM; 278 var band = 2 + qrMm + 7; // float margin box: 2mm top + QR + 7mm bottom 279 var narrow = BOX_W - (qrMm + 7); // line width beside the float (7mm margin-left) 280 var runs = []; 281 var y = 0; 282 function availAt(yy) { return yy < band ? narrow : BOX_W; } 283 var descLines = wrap(m.description, "times", size, availAt, 0, lineH); 284 descLines.forEach(function (line, i) { 285 // justify: pad word gaps to fill the line; last line stays ragged 286 var gaps = line.text.split(" ").length - 1; 287 var ws = (i < descLines.length - 1 && gaps > 0) 288 ? (availAt(line.yTop) - tw(line.text, "times", size)) * MM / gaps : 0; 289 runs.push({ text: line.text, x: 0, yTop: line.yTop, ws: ws }); 290 y = line.yTop + lineH; 291 }); 292 y += gapTopMm; // ul margin-top 293 var indent = 1.2 * em; // ul padding-left 294 var bulletW = tw("•", "times", size); 295 window.fmt.detailItems(m).forEach(function (item) { 296 y += 0.15 * em; // li margin-top 297 var lines = wrap(item, "times", size, function (yy) { return availAt(yy) - indent; }, y, lineH); 298 runs.push({ text: "•", x: indent - 0.45 * em - bulletW, yTop: lines[0].yTop }); 299 lines.forEach(function (line) { 300 runs.push({ text: line.text, x: indent, yTop: line.yTop }); 301 y = line.yTop + lineH; 302 }); 303 }); 304 y += gapBotMm; // ul margin-bottom (counts toward the flow's BFC height) 305 return { height: Math.max(y, band), size: size, lineH: lineH, qr: qrMm, runs: runs }; 306 } 307 308 // fitFlow + fitQr's mirror: fit at the max QR, then final layout at the grid-aligned QR 309 function fitPdfFlow(m, maxH) { 310 var lo = 32, hi = 256; // quarter-px units 311 while (lo < hi) { 312 var mid = Math.ceil((lo + hi) / 2); 313 if (layoutFlow(m, mid / 4, QR_BODY, 7, 7).height <= maxH) { lo = mid; } else { hi = mid - 1; } 314 } 315 return layoutFlow(m, lo / 4, alignedQrMm(lo / 4 * 0.75 * 1.3 / MM), 7, 7); 316 } 317 318 // QR as rects (white backing + black per-row runs), top-left at (x, y). emit places one rect. 319 function qrPaint(emit, text, x, y, sizeMm) { 320 var matrix = window.qr.createMatrix(text); 321 var n = matrix.length; 322 var mod = sizeMm / n; 323 emit(x, y, sizeMm, sizeMm, 1); 324 for (var r = 0; r < n; r++) { 325 var c = 0; 326 while (c < n) { 327 if (!matrix[r][c]) { c++; continue; } 328 var start = c; 329 while (c < n && matrix[r][c]) { c++; } 330 emit(x + start * mod, y + r * mod, (c - start) * mod, mod, 0); 331 } 332 } 333 } 334 335 // record a QR's rects once so the 10 tabs replay them 336 function qrRuns(text, sizeMm) { 337 var runs = []; 338 qrPaint(function (x, y, w, h, gray) { runs.push([x, y, w, h, gray]); }, text, 0, 0, sizeMm); 339 return runs; 340 } 341 342 // Tab text column (bold title, when, where) at base size s pt, in inner-local coords: 343 // u along the 78mm axis, v across the 19.73mm one. 344 function layoutTabText(m, s) { 345 var runs = []; 346 var v = 0; 347 function add(str, font, sizePt, lineH, marginTop) { 348 v += marginTop; 349 wrap(str, font, sizePt, function () { return TAB_TEXT_W; }, v, lineH).forEach(function (line) { 350 runs.push({ text: line.text, font: font, size: sizePt, vTop: line.yTop, lineH: lineH }); 351 v = line.yTop + lineH; 352 }); 353 } 354 add(m.title, "timesBold", 1.3 * s, 1.3 * s * 1.1 / MM, 0); 355 add(window.fmt.startLine(m), "times", s, s * 1.15 / MM, 0.1 * s / MM); 356 add(m.venue, "times", s, s * 1.15 / MM, 0.1 * s / MM); 357 return { runs: runs, height: v }; 358 } 359 360 // fitTabs's mirror: largest base pt size whose text stack fits the strip 361 function fitPdfTab(m) { 362 var lo = 6, hi = 16; 363 while (lo < hi) { 364 var mid = Math.ceil((lo + hi) / 2); 365 if (layoutTabText(m, mid).height <= TAB_CONTENT_H) { lo = mid; } else { hi = mid - 1; } 366 } 367 return layoutTabText(m, lo); 368 } 369 370 // One tab's rotated content. The inner box is centered on its cell and rotated 90deg CW, 371 // so local (u, v) maps to flyer-local (cx + TAB_W/2 - v, cy - TABS_H/2 + u): u runs down 372 // the page, QR at the foot. Rects swap w/h; text uses the writer's rotate90. 373 function paintTab(doc, i, tabText, runs) { 374 var cx = i * TAB_W + TAB_W / 2; 375 var cy = BOX_H - TABS_H / 2; 376 function mapX(v) { return cx + TAB_W / 2 - v; } 377 function mapY(u) { return cy - TABS_H / 2 + u; } 378 var vText = TAB_PAD_V + (TAB_CONTENT_H - tabText.height) / 2; // align-items: center 379 tabText.runs.forEach(function (run) { 380 var v = vText + run.vTop + baselineOff(run.size, run.lineH); 381 doc.text(run.text, px(mapX(v)), py(mapY(TAB_PAD_TEXT)), 382 { font: run.font, size: run.size, rotate90: true }); 383 }); 384 var u0 = TAB_PAD_TEXT + TAB_TEXT_W + TAB_GAP; 385 var v0 = TAB_PAD_V + (TAB_CONTENT_H - QR_TAB) / 2; 386 runs.forEach(function (r) { 387 var u = u0 + r[0], v = v0 + r[1], w = r[2], h = r[3]; 388 doc.rect(px(mapX(v + h)), py(mapY(u) + w), h * MM, w * MM, r[4]); 389 }); 390 } 391 392 // the tear-from-body line plus the 11 cut lines bounding the 10 tabs 393 function paintCutLines(doc) { 394 var top = BOX_H - TABS_H; 395 doc.dashedLine(px(0), py(top), px(BOX_W), py(top), DASH); 396 for (var i = 0; i <= TAB_COUNT; i++) { 397 doc.dashedLine(px(i * TAB_W), py(top), px(i * TAB_W), py(BOX_H), DASH); 398 } 399 } 400 401 // Draw the whole flyer for `m`; returns the PDF file bytes. 402 function buildPdf(m) { 403 var doc = window.pdf.create(PAGE_W * MM, PAGE_H * MM); 404 var title = layoutTitle(m); 405 title.lines.forEach(function (line) { 406 doc.text(line.text, px(0), py(line.yTop + baselineOff(TITLE_PT, TITLE_LINE_H)), 407 { font: "times", size: TITLE_PT }); 408 }); 409 var flowTop = title.block; 410 var avail = BOX_H - TABS_H - flowTop; 411 // build()'s mirror: ink-equalized gaps with the fit slack split a third per gap 412 var titleEm = TITLE_PT / MM; // title font size in mm 413 function gapComp(e) { return (leadBelow(1.3) + leadAbove(1.3)) * e - leadBelow(1.05) * titleEm; } 414 var flow = fitPdfFlow(m, avail); 415 var comp = gapComp(flow.size / MM); 416 if (comp > 0) { 417 flow = fitPdfFlow(m, avail - comp); 418 comp = gapComp(flow.size / MM); 419 } 420 var s3 = Math.max(avail - comp - flow.height, 0) / 3; 421 flow = layoutFlow(m, flow.size / 0.75, flow.qr, 422 7 + s3, 7 + s3 + leadAbove(1.3) * flow.size / MM); 423 flowTop += s3 + leadBelow(1.3) * flow.size / MM - leadBelow(1.05) * titleEm; 424 flow.runs.forEach(function (run) { 425 doc.text(run.text, px(run.x), py(flowTop + run.yTop + baselineOff(flow.size, flow.lineH)), 426 { font: "times", size: flow.size, wordSpacing: run.ws }); 427 }); 428 var url = window.fmt.eventUrl(m); 429 qrPaint(function (x, y, w, h, gray) { 430 doc.rect(px(x), py(y + h), w * MM, h * MM, gray); 431 }, url, BOX_W - flow.qr, flowTop + 2, flow.qr); 432 paintCutLines(doc); 433 var tabText = fitPdfTab(m); 434 var tabQr = qrRuns(url, QR_TAB); 435 for (var i = 0; i < TAB_COUNT; i++) { paintTab(doc, i, tabText, tabQr); } 436 return doc.end(); 437 } 438 439 var pdfUrl = null; 440 441 // Safari can't script-print a blob PDF (WebKit), so open it in a new tab and let the 442 // user print from Safari's viewer. Synchronous so window.open keeps the gesture 443 // allowance. Old blob URL revoked on next build, not after open (tab loads it async). 444 function openPdf(m) { 445 var bytes = buildPdf(m); 446 if (pdfUrl) { URL.revokeObjectURL(pdfUrl); } 447 pdfUrl = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })); 448 window.open(pdfUrl, "_blank"); 449 } 450 451 window.addEventListener("pagehide", function () { 452 if (pdfUrl) { URL.revokeObjectURL(pdfUrl); pdfUrl = null; } 453 }); 454 455 // afterprint, not synchronously after print(): some engines render async 456 window.addEventListener("afterprint", function () { 457 if (flyerEl) { flyerEl.textContent = ""; } 458 building = false; 459 }); 460 461 function printFlyer(m) { 462 if (!m) { return; } 463 if (isSafari) { openPdf(m); return; } 464 if (building) { return; } 465 building = true; 466 build(m); 467 window.print(); 468 } 469 470 // Cmd/Ctrl+P with an event selected: beforeprint can't cancel Safari's dialog, but 471 // keydown can preempt it and route to the PDF. 472 if (isSafari) { 473 window.addEventListener("keydown", function (e) { 474 if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && 475 (e.key === "p" || e.key === "P")) { 476 var m = window.panel && window.panel.getSelectedEvent && window.panel.getSelectedEvent(); 477 if (!m) { return; } // intro showing -> let Safari print the page 478 e.preventDefault(); 479 openPdf(m); 480 } 481 }); 482 } 483 484 // beforeprint fires before the dialog, so build here so any browser-initiated print 485 // gets the flyer instead of the live app. (Link path sets `building` and we skip.) 486 window.addEventListener("beforeprint", function () { 487 if (isSafari || building) { return; } 488 var m = window.panel && window.panel.getSelectedEvent && window.panel.getSelectedEvent(); 489 if (!m) { return; } // intro showing -> let the browser proceed 490 building = true; 491 build(m); 492 }); 493 494 window.flyer = { print: printFlyer, buildPdf: buildPdf, enabled: true }; 495 })();