commit c43fb140f19986879a7ceff383e8d35ea556921c
parent 64adfb6aae1a6c3fd89a5026bdf12102f02a4458
Author: Hunter
Date:   Mon, 27 Jul 2026 17:17:12 -0400

support printing from Safari; ⌘+E to export PDF

Diffstat:
Mindex.html | 1+
Apdf.js | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mreadme.md | 8++++----
Mscript.js | 24++++--------------------
4 files changed, 233 insertions(+), 24 deletions(-)

diff --git a/index.html b/index.html @@ -17,5 +17,6 @@ <button id="addImagesBtn" class="add-images-btn">Add images</button> <script src="script.js"></script> + <script src="pdf.js"></script> </body> </html> diff --git a/pdf.js b/pdf.js @@ -0,0 +1,224 @@ +(function () { + var MM = 72 / 25.4; // mm -> PDF points + var DPI = 600; // photo raster resolution + var JPEG_QUALITY = 0.95; // near lossless + var PX = DPI / 25.4; // mm -> canvas px + var GRID_GRAY = 0xd0 / 0xff;// print grid line color (style.css --default-grid #d0d0d0) + var LINE_PT = 0.75; // grid line width in points (1 CSS px) + var DASH_MM = 0.75; // dash length + var GAP_MM = 0.4; // nominal gap; sets dashes-per-cell, then absorbs rounding slack + + function num(n) { + var s = n.toFixed(6).replace(/\.?0+$/, ""); + return s === "-0" ? "0" : s; + } + + // Uint8Array -> binary string (one char per byte) so string length == byte length + function bytesToBinary(bytes) { + var CHUNK = 0x8000; + var parts = []; + for (var i = 0; i < bytes.length; i += CHUNK) { + parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))); + } + return parts.join(""); + } + + function createPdf(widthPt, heightPt) { + var ops = []; + var xobjects = []; // embedded JPEG images, in draw order + var lastGray = null; + + // filled rect anchored at its bottom-left; gray 0 = black, 1 = white + function rect(x, y, w, h, gray) { + var g = gray || 0; + if (g !== lastGray) { + ops.push(num(g) + " g"); + lastGray = g; + } + ops.push(num(x) + " " + num(y) + " " + num(w) + " " + num(h) + " re f"); + } + + // opts.phase shifts the dash pattern along the path, to center a dash on a given point + function dashedLine(x1, y1, x2, y2, opts) { + var g = opts.gray == null ? 0 : opts.gray; + var phase = opts.phase || 0; + ops.push("q [" + num(opts.dash[0]) + " " + num(opts.dash[1]) + "] " + num(phase) + " d " + + num(opts.width) + " w " + num(g) + " G " + + num(x1) + " " + num(y1) + " m " + num(x2) + " " + num(y2) + " l S Q"); + } + + // place a JPEG with its bottom-left at (x, y), drawn w x h points + function image(jpegBytes, imgW, imgH, x, y, w, h) { + var idx = xobjects.length; + xobjects.push({ w: imgW, h: imgH, bin: bytesToBinary(jpegBytes) }); + ops.push("q " + num(w) + " 0 0 " + num(h) + " " + num(x) + " " + num(y) + + " cm /Im" + idx + " Do Q"); + } + + // assemble as a binary string (char codes <= 0xFF) so string offsets are byte offsets + function end() { + var stream = ops.join("\n"); + // image XObjects are numbered after the four fixed objects + var xobjEntries = xobjects.map(function (_, i) { + return "/Im" + i + " " + (5 + i) + " 0 R"; + }).join(" "); + var resources = xobjects.length ? "/XObject << " + xobjEntries + " >>" : ""; + var objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + num(widthPt) + " " + num(heightPt) + "]" + + " /Resources << " + resources + " >> /Contents 4 0 R >>", + "<< /Length " + stream.length + " >>\nstream\n" + stream + "\nendstream" + ]; + xobjects.forEach(function (im) { + objects.push("<< /Type /XObject /Subtype /Image /Width " + im.w + " /Height " + im.h + + " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + + im.bin.length + " >>\nstream\n" + im.bin + "\nendstream"); + }); + var out = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; + var offsets = []; + objects.forEach(function (body, i) { + offsets.push(out.length); + out += (i + 1) + " 0 obj\n" + body + "\nendobj\n"; + }); + var xref = out.length; + out += "xref\n0 " + (objects.length + 1) + "\n0000000000 65535 f \n"; + offsets.forEach(function (off) { + out += ("000000000" + off).slice(-10) + " 00000 n \n"; + }); + out += "trailer\n<< /Size " + (objects.length + 1) + " /Root 1 0 R >>\n" + + "startxref\n" + xref + "\n%%EOF\n"; + var bytes = new Uint8Array(out.length); + for (var i = 0; i < out.length; i++) { bytes[i] = out.charCodeAt(i); } + return bytes; + } + + return { rect: rect, dashedLine: dashedLine, image: image, end: end }; + } + + // "memori · YY·MM·DD·HH·MM·SS.pdf" + function pdfFilename() { + var d = new Date(); + var p = function (n) { return String(n).padStart(2, "0"); }; + var ts = [d.getFullYear() % 100, d.getMonth() + 1, d.getDate(), + d.getHours(), d.getMinutes(), d.getSeconds()].map(p).join("·"); + return "memori · " + ts + ".pdf"; + } + + // back-to-front by stacking order, so overlaps paint like on screen + function imagesInZOrder() { + return images.slice().sort(function (a, b) { + return (parseInt(a.container.style.zIndex, 10) || 0) - + (parseInt(b.container.style.zIndex, 10) || 0); + }); + } + + // Rasterize one image at the size of the area it covers on the grid, reproducing + // updateImagePosition's transform. + function rasterizeImage(img) { + var el = img.container.querySelector("img"); + if (!el || !img.naturalWidth || !img.naturalHeight) { return null; } + + var cellPx = CELL_SIZE_MM * PX; + var cw = Math.round(img.widthCells * cellPx), ch = Math.round(img.heightCells * cellPx); + var canvas = document.createElement("canvas"); + canvas.width = cw; + canvas.height = ch; + var ctx = canvas.getContext("2d"); + + // cover-fit scale, recomputed here as the print CSS path does + var rotated = img.rotation % 180 !== 0; + var effW = rotated ? img.naturalHeight : img.naturalWidth; + var effH = rotated ? img.naturalWidth : img.naturalHeight; + var scale = Math.max(cw / effW, ch / effH) * img.userScale; + + ctx.translate(cw / 2, ch / 2); + ctx.scale(scale, scale); + ctx.rotate(img.rotation * Math.PI / 180); + ctx.translate(img.panX, img.panY); + ctx.drawImage(el, -img.naturalWidth / 2, -img.naturalHeight / 2, img.naturalWidth, img.naturalHeight); + return canvas; + } + + function drawGrid(doc, cellPt, mediaW, mediaH) { + var perCell = Math.max(1, Math.round(CELL_SIZE_MM / (DASH_MM + GAP_MM))); + var period = cellPt / perCell; + var dash = Math.min(DASH_MM * MM, period); + var half = LINE_PT / 2; + var opts = { dash: [dash, period - dash], width: LINE_PT, gray: GRID_GRAY, phase: dash / 2 - half }; + + for (var c = 0; c <= GRID_COLS; c++) { + var x = c * cellPt + half; + doc.dashedLine(x, 0, x, mediaH, opts); + } + for (var r = 0; r <= GRID_ROWS; r++) { + var y = mediaH - (r * cellPt + half); // PDF origin is bottom-left + doc.dashedLine(0, y, mediaW, y, opts); + } + } + + function jpegBytes(canvas) { + var dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY); + var bin = atob(dataUrl.slice(dataUrl.indexOf(",") + 1)); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); } + return bytes; + } + + function buildPdf() { + var cellPt = CELL_SIZE_MM * MM; + var mediaW = GRID_COLS * cellPt + LINE_PT; + var mediaH = GRID_ROWS * cellPt + LINE_PT; + var doc = createPdf(mediaW, mediaH); + + doc.rect(0, 0, mediaW, mediaH, 1); // white sheet + drawGrid(doc, cellPt, mediaW, mediaH); + + // photos on top; PDF origin is bottom-left, so flip the cell's y + imagesInZOrder().forEach(function (img) { + var canvas = rasterizeImage(img); + if (!canvas) { return; } + var x = img.xCell * cellPt; + var y = mediaH - (img.yCell + img.heightCells) * cellPt; + doc.image(jpegBytes(canvas), canvas.width, canvas.height, + x, y, img.widthCells * cellPt, img.heightCells * cellPt); + }); + + return doc.end(); + } + + function exportPdf() { + var url = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" })); + var a = document.createElement("a"); + a.href = url; + a.download = pdfFilename(); + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 0); + } + + var printUrl = null; + function openPdfForPrinting() { + if (printUrl) { URL.revokeObjectURL(printUrl); } + printUrl = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" })); + window.open(printUrl, "_blank"); + } + + window.addEventListener("keydown", function (e) { + if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) { return; } + var key = e.key.toLowerCase(); + if (key === "e") { + e.preventDefault(); + exportPdf(); + } else if (key === "p" && isUsingSafari) { + // keydown preempts Safari's print dialog (beforeprint can't cancel it) + e.preventDefault(); + openPdfForPrinting(); + } + }); + + window.addEventListener("pagehide", function () { + if (printUrl) { URL.revokeObjectURL(printUrl); printUrl = null; } + }); +})(); diff --git a/readme.md b/readme.md @@ -26,9 +26,6 @@ Different Techo types use differently-sized grid paper. Use the links below to s | Weeks<br>Weeks Mega | [Use 3.55mm grid ↗](https://hunterirving.github.io/memori?grid-size=3.55mm) | | 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. | -> [!IMPORTANT] -> Printing from Safari is not currently supported. Please use Firefox or Chrome instead. - ### controls - 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> @@ -61,7 +58,10 @@ Different Techo types use differently-sized grid paper. Use the links below to s - press ```F2``` to select from one of six user interface themes (you may also have to hold ```Fn```)<br> <img src="readme_images/select_theme.gif"> -Once you've filled out the page, press ```⌘ + P``` to print using your system's default print dialog. +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>. +<br> + +You can also press ```⌘ + E``` to export your work as a PDF, to print later or somewhere else. <br> >[!TIP] diff --git a/script.js b/script.js @@ -62,24 +62,17 @@ function parseCellSizeFromURL() { // Check if size is valid if (isNaN(size)) { - if (!isUsingSafari) { - showPopup('Invalid grid size. Using default 4mm.', 3000); - } + showPopup('Invalid grid size. Using default 4mm.', 3000); return 4; } // Check if size is within reasonable bounds 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); - } + showPopup(`Grid size must be between ${MIN_CELL_SIZE_MM}mm and ${MAX_CELL_SIZE_MM}mm. Using default 4mm.`, 3500); return 4; } - // Valid size - only show popup if not Safari - if (!isUsingSafari) { - showPopup(`Grid set to ${size}mm`); - } + showPopup(`Grid set to ${size}mm`); return size; } @@ -316,7 +309,7 @@ document.addEventListener('paste', async (e) => { } if (imageFiles.length === 0) { - if (!isUsingSafari) showPopup('No image found in clipboard'); + showPopup('No image found in clipboard'); return; } @@ -1205,15 +1198,6 @@ document.addEventListener('keyup', (e) => { // Load theme on page load loadThemeFromLocalStorage(); -// 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); -} - // Warn before leaving page if images are present window.addEventListener('beforeunload', (e) => { if (images.length > 0) {