support printing from Safari; ⌘+E to export PDF

Hunter
Monday, July 27th, 2026 at 5:17 PM

index.html+1 
pdf.jsnew
readme.md+4-4
script.js+4-20

index.html
17  	<button id="addImagesBtn" class="add-images-btn">Add images</button>
18  
19  	<script src="script.js"></script>
20 +	<script src="pdf.js"></script>
21  </body>
22  </html>
pdf.js
  1 +(function () {
  2 +	var MM = 72 / 25.4;         // mm -> PDF points
  3 +	var DPI = 600;              // photo raster resolution
  4 +	var JPEG_QUALITY = 0.95;	// near lossless
  5 +	var PX = DPI / 25.4;        // mm -> canvas px
  6 +	var GRID_GRAY = 0xd0 / 0xff;// print grid line color (style.css --default-grid #d0d0d0)
  7 +	var LINE_PT = 0.75;         // grid line width in points (1 CSS px)
  8 +	var DASH_MM = 0.75;         // dash length
  9 +	var GAP_MM = 0.4;           // nominal gap; sets dashes-per-cell, then absorbs rounding slack
 10 +
 11 +	function num(n) {
 12 +		var s = n.toFixed(6).replace(/\.?0+$/, "");
 13 +		return s === "-0" ? "0" : s;
 14 +	}
 15 +
 16 +	// Uint8Array -> binary string (one char per byte) so string length == byte length
 17 +	function bytesToBinary(bytes) {
 18 +		var CHUNK = 0x8000;
 19 +		var parts = [];
 20 +		for (var i = 0; i < bytes.length; i += CHUNK) {
 21 +			parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
 22 +		}
 23 +		return parts.join("");
 24 +	}
 25 +
 26 +	function createPdf(widthPt, heightPt) {
 27 +		var ops = [];
 28 +		var xobjects = []; // embedded JPEG images, in draw order
 29 +		var lastGray = null;
 30 +
 31 +		// filled rect anchored at its bottom-left; gray 0 = black, 1 = white
 32 +		function rect(x, y, w, h, gray) {
 33 +			var g = gray || 0;
 34 +			if (g !== lastGray) {
 35 +				ops.push(num(g) + " g");
 36 +				lastGray = g;
 37 +			}
 38 +			ops.push(num(x) + " " + num(y) + " " + num(w) + " " + num(h) + " re f");
 39 +		}
 40 +
 41 +		// opts.phase shifts the dash pattern along the path, to center a dash on a given point
 42 +		function dashedLine(x1, y1, x2, y2, opts) {
 43 +			var g = opts.gray == null ? 0 : opts.gray;
 44 +			var phase = opts.phase || 0;
 45 +			ops.push("q [" + num(opts.dash[0]) + " " + num(opts.dash[1]) + "] " + num(phase) + " d " +
 46 +				num(opts.width) + " w " + num(g) + " G " +
 47 +				num(x1) + " " + num(y1) + " m " + num(x2) + " " + num(y2) + " l S Q");
 48 +		}
 49 +
 50 +		// place a JPEG with its bottom-left at (x, y), drawn w x h points
 51 +		function image(jpegBytes, imgW, imgH, x, y, w, h) {
 52 +			var idx = xobjects.length;
 53 +			xobjects.push({ w: imgW, h: imgH, bin: bytesToBinary(jpegBytes) });
 54 +			ops.push("q " + num(w) + " 0 0 " + num(h) + " " + num(x) + " " + num(y) +
 55 +				" cm /Im" + idx + " Do Q");
 56 +		}
 57 +
 58 +		// assemble as a binary string (char codes <= 0xFF) so string offsets are byte offsets
 59 +		function end() {
 60 +			var stream = ops.join("\n");
 61 +			// image XObjects are numbered after the four fixed objects
 62 +			var xobjEntries = xobjects.map(function (_, i) {
 63 +				return "/Im" + i + " " + (5 + i) + " 0 R";
 64 +			}).join(" ");
 65 +			var resources = xobjects.length ? "/XObject << " + xobjEntries + " >>" : "";
 66 +			var objects = [
 67 +				"<< /Type /Catalog /Pages 2 0 R >>",
 68 +				"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
 69 +				"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + num(widthPt) + " " + num(heightPt) + "]" +
 70 +					" /Resources << " + resources + " >> /Contents 4 0 R >>",
 71 +				"<< /Length " + stream.length + " >>\nstream\n" + stream + "\nendstream"
 72 +			];
 73 +			xobjects.forEach(function (im) {
 74 +				objects.push("<< /Type /XObject /Subtype /Image /Width " + im.w + " /Height " + im.h +
 75 +					" /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " +
 76 +					im.bin.length + " >>\nstream\n" + im.bin + "\nendstream");
 77 +			});
 78 +			var out = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
 79 +			var offsets = [];
 80 +			objects.forEach(function (body, i) {
 81 +				offsets.push(out.length);
 82 +				out += (i + 1) + " 0 obj\n" + body + "\nendobj\n";
 83 +			});
 84 +			var xref = out.length;
 85 +			out += "xref\n0 " + (objects.length + 1) + "\n0000000000 65535 f \n";
 86 +			offsets.forEach(function (off) {
 87 +				out += ("000000000" + off).slice(-10) + " 00000 n \n";
 88 +			});
 89 +			out += "trailer\n<< /Size " + (objects.length + 1) + " /Root 1 0 R >>\n" +
 90 +				"startxref\n" + xref + "\n%%EOF\n";
 91 +			var bytes = new Uint8Array(out.length);
 92 +			for (var i = 0; i < out.length; i++) { bytes[i] = out.charCodeAt(i); }
 93 +			return bytes;
 94 +		}
 95 +
 96 +		return { rect: rect, dashedLine: dashedLine, image: image, end: end };
 97 +	}
 98 +
 99 +	// "memori · YY·MM·DD·HH·MM·SS.pdf"
100 +	function pdfFilename() {
101 +		var d = new Date();
102 +		var p = function (n) { return String(n).padStart(2, "0"); };
103 +		var ts = [d.getFullYear() % 100, d.getMonth() + 1, d.getDate(),
104 +			d.getHours(), d.getMinutes(), d.getSeconds()].map(p).join("·");
105 +		return "memori · " + ts + ".pdf";
106 +	}
107 +
108 +	// back-to-front by stacking order, so overlaps paint like on screen
109 +	function imagesInZOrder() {
110 +		return images.slice().sort(function (a, b) {
111 +			return (parseInt(a.container.style.zIndex, 10) || 0) -
112 +				(parseInt(b.container.style.zIndex, 10) || 0);
113 +		});
114 +	}
115 +
116 +	// Rasterize one image at the size of the area it covers on the grid, reproducing
117 +	// updateImagePosition's transform.
118 +	function rasterizeImage(img) {
119 +		var el = img.container.querySelector("img");
120 +		if (!el || !img.naturalWidth || !img.naturalHeight) { return null; }
121 +
122 +		var cellPx = CELL_SIZE_MM * PX;
123 +		var cw = Math.round(img.widthCells * cellPx), ch = Math.round(img.heightCells * cellPx);
124 +		var canvas = document.createElement("canvas");
125 +		canvas.width = cw;
126 +		canvas.height = ch;
127 +		var ctx = canvas.getContext("2d");
128 +
129 +		// cover-fit scale, recomputed here as the print CSS path does
130 +		var rotated = img.rotation % 180 !== 0;
131 +		var effW = rotated ? img.naturalHeight : img.naturalWidth;
132 +		var effH = rotated ? img.naturalWidth : img.naturalHeight;
133 +		var scale = Math.max(cw / effW, ch / effH) * img.userScale;
134 +
135 +		ctx.translate(cw / 2, ch / 2);
136 +		ctx.scale(scale, scale);
137 +		ctx.rotate(img.rotation * Math.PI / 180);
138 +		ctx.translate(img.panX, img.panY);
139 +		ctx.drawImage(el, -img.naturalWidth / 2, -img.naturalHeight / 2, img.naturalWidth, img.naturalHeight);
140 +		return canvas;
141 +	}
142 +
143 +	function drawGrid(doc, cellPt, mediaW, mediaH) {
144 +		var perCell = Math.max(1, Math.round(CELL_SIZE_MM / (DASH_MM + GAP_MM)));
145 +		var period = cellPt / perCell;
146 +		var dash = Math.min(DASH_MM * MM, period);
147 +		var half = LINE_PT / 2;
148 +		var opts = { dash: [dash, period - dash], width: LINE_PT, gray: GRID_GRAY, phase: dash / 2 - half };
149 +
150 +		for (var c = 0; c <= GRID_COLS; c++) {
151 +			var x = c * cellPt + half;
152 +			doc.dashedLine(x, 0, x, mediaH, opts);
153 +		}
154 +		for (var r = 0; r <= GRID_ROWS; r++) {
155 +			var y = mediaH - (r * cellPt + half); // PDF origin is bottom-left
156 +			doc.dashedLine(0, y, mediaW, y, opts);
157 +		}
158 +	}
159 +
160 +	function jpegBytes(canvas) {
161 +		var dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
162 +		var bin = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
163 +		var bytes = new Uint8Array(bin.length);
164 +		for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
165 +		return bytes;
166 +	}
167 +
168 +	function buildPdf() {
169 +		var cellPt = CELL_SIZE_MM * MM;
170 +		var mediaW = GRID_COLS * cellPt + LINE_PT;
171 +		var mediaH = GRID_ROWS * cellPt + LINE_PT;
172 +		var doc = createPdf(mediaW, mediaH);
173 +
174 +		doc.rect(0, 0, mediaW, mediaH, 1); // white sheet
175 +		drawGrid(doc, cellPt, mediaW, mediaH);
176 +
177 +		// photos on top; PDF origin is bottom-left, so flip the cell's y
178 +		imagesInZOrder().forEach(function (img) {
179 +			var canvas = rasterizeImage(img);
180 +			if (!canvas) { return; }
181 +			var x = img.xCell * cellPt;
182 +			var y = mediaH - (img.yCell + img.heightCells) * cellPt;
183 +			doc.image(jpegBytes(canvas), canvas.width, canvas.height,
184 +				x, y, img.widthCells * cellPt, img.heightCells * cellPt);
185 +		});
186 +
187 +		return doc.end();
188 +	}
189 +
190 +	function exportPdf() {
191 +		var url = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" }));
192 +		var a = document.createElement("a");
193 +		a.href = url;
194 +		a.download = pdfFilename();
195 +		document.body.appendChild(a);
196 +		a.click();
197 +		a.remove();
198 +		setTimeout(function () { URL.revokeObjectURL(url); }, 0);
199 +	}
200 +
201 +	var printUrl = null;
202 +	function openPdfForPrinting() {
203 +		if (printUrl) { URL.revokeObjectURL(printUrl); }
204 +		printUrl = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" }));
205 +		window.open(printUrl, "_blank");
206 +	}
207 +
208 +	window.addEventListener("keydown", function (e) {
209 +		if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) { return; }
210 +		var key = e.key.toLowerCase();
211 +		if (key === "e") {
212 +			e.preventDefault();
213 +			exportPdf();
214 +		} else if (key === "p" && isUsingSafari) {
215 +			// keydown preempts Safari's print dialog (beforeprint can't cancel it)
216 +			e.preventDefault();
217 +			openPdfForPrinting();
218 +		}
219 +	});
220 +
221 +	window.addEventListener("pagehide", function () {
222 +		if (printUrl) { URL.revokeObjectURL(printUrl); printUrl = null; }
223 +	});
224 +})();
readme.md
26  | Weeks<br>Weeks Mega | [Use 3.55mm grid ↗](https://hunterirving.github.io/memori?grid-size=3.55mm) |
27  | Other planners | Add a "grid-size" parameter to the url like so:<br><a href="https://hunterirving.github.io/memori/?grid-size=5mm">https://<area>hunterirving.github.io/memori/<b>?grid-size=5mm</b></a><br>This value can be anywhere between 2mm and 10mm. |
28  
   -> [!IMPORTANT]
   -> Printing from Safari is not currently supported. Please use Firefox or Chrome instead.
   -
29  ### controls
30  
31  - after opening memori in your web browser, drag one or more images from your desktop onto the grid (or press `⌘ + v` to paste image data from your clipboard)<br>

58  - press ```F2``` to select from one of six user interface themes (you may also have to hold ```Fn```)<br>
59  <img src="readme_images/select_theme.gif">
60  
   -Once you've filled out the page, press ```⌘ + P``` to print using your system's default print dialog.
61 +Once you've filled out the page, press ```⌘ + P``` to print using your system's default print dialog <small>(in Safari, this will open a formatted PDF in a new tab)</small>.
62 +<br>
63 +
64 +You can also press ```⌘ + E``` to export your work as a PDF, to print later or somewhere else.
65  <br>
66  
67  >[!TIP]
script.js
  62  
  63  		// Check if size is valid
  64  		if (isNaN(size)) {
     -			if (!isUsingSafari) {
     -				showPopup('Invalid grid size. Using default 4mm.', 3000);
     -			}
  65 +			showPopup('Invalid grid size. Using default 4mm.', 3000);
  66  			return 4;
  67  		}
  68  
  69  		// Check if size is within reasonable bounds
  70  		if (size < MIN_CELL_SIZE_MM || size > MAX_CELL_SIZE_MM) {
     -			if (!isUsingSafari) {
     -				showPopup(`Grid size must be between ${MIN_CELL_SIZE_MM}mm and ${MAX_CELL_SIZE_MM}mm. Using default 4mm.`, 3500);
     -			}
  71 +			showPopup(`Grid size must be between ${MIN_CELL_SIZE_MM}mm and ${MAX_CELL_SIZE_MM}mm. Using default 4mm.`, 3500);
  72  			return 4;
  73  		}
  74  
     -		// Valid size - only show popup if not Safari
     -		if (!isUsingSafari) {
     -			showPopup(`Grid set to ${size}mm`);
     -		}
  75 +		showPopup(`Grid set to ${size}mm`);
  76  		return size;
  77  	}
  78  

 309  	}
 310  
 311  	if (imageFiles.length === 0) {
     -		if (!isUsingSafari) showPopup('No image found in clipboard');
 312 +		showPopup('No image found in clipboard');
 313  		return;
 314  	}
 315  

1198  // Load theme on page load
1199  loadThemeFromLocalStorage();
1200  
     -// Show Safari warning if applicable
     -if (isUsingSafari) {
     -	const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
     -	const safariMessage = isMobile
     -		? 'Printing from Safari is not currently supported.'
     -		: 'Printing from Safari is not currently supported. Please use Firefox or Chrome instead.';
     -	showPopup(safariMessage, 5000);
     -}
     -
1201  // Warn before leaving page if images are present
1202  window.addEventListener('beforeunload', (e) => {
1203  	if (images.length > 0) {