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