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