support printing from Safari; ⌘+E to export PDF
Hunter
Monday, July 27th, 2026 at 5:17 PM
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>
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 +})();
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]
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) {