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 })();