1/** 2 * File ⸠Save ⸠Save as PDF: every page of the open document as a page of a PDF file. 3 * Each page is its flattened image (on white) embedded as a JPEG, sized at 96 dpi 4 * (1 px = 0.75 pt), so the PDF has the document's proportions. The PDF is written here, 5 * without a library. (Not added to recent files: the editor cannot open a PDF.) 6 */ 7class PdfExportService { 8 static suggestedFileName(lm) { 9 if (!lm) return "image.pdf"; 10 const remembered = lm.lastSaveAsFileName; 11 if (typeof remembered === "string" && remembered.trim() && /\.pdf$/i.test(remembered.trim())) { 12 return remembered.trim(); 13 } 14 const base = String(lm.fileName || "image").trim() || "image"; 15 const simple = (base.split(/[/\\]/).pop() || "image") 16 .replace(/\.(png|jpe?g|jpeg|webp|gif|bmp|picx|json|svg|pdf)$/i, ""); 17 return `${simple || "image"}.pdf`; 18 } 19 20 /** The flattened canvas of every page (the current page only when the project has no pages). */ 21 static pageCanvases() { 22 const pages = app.currentProject?.pages; 23 const lms = Array.isArray(pages) && pages.length 24 ? pages.map((page) => page.lm) 25 : [app.lm]; 26 return lms 27 .filter(Boolean) 28 .map((lm) => lm.getFlatCanvas()) 29 .filter((canvas) => canvas && canvas.width > 0 && canvas.height > 0); 30 } 31 32 /** A JPEG of the canvas on white (JPEG has no transparency), as bytes. */ 33 static async jpegBytes(canvas, quality = 0.92) { 34 const flat = document.createElement("canvas"); 35 flat.width = canvas.width; 36 flat.height = canvas.height; 37 const g = flat.getContext("2d"); 38 g.fillStyle = "#ffffff"; 39 g.fillRect(0, 0, flat.width, flat.height); 40 g.drawImage(canvas, 0, 0); 41 const blob = await new Promise((resolve) => flat.toBlob(resolve, "image/jpeg", quality)); 42 if (!blob) throw new Error("JPEG encoding failed"); 43 return new Uint8Array(await blob.arrayBuffer()); 44 } 45 46 /** A PDF with one page per canvas. */ 47 static async buildPdf(canvases) { 48 const encoder = new TextEncoder(); 49 const chunks = []; 50 const offsets = []; 51 let length = 0; 52 const write = (part) => { 53 const bytes = typeof part === "string" ? encoder.encode(part) : part; 54 chunks.push(bytes); 55 length += bytes.length; 56 }; 57 const object = (id, parts) => { 58 offsets[id] = length; 59 write(`${id} 0 obj\n`); 60 parts.forEach(write); 61 write("\nendobj\n"); 62 }; 63 const pt = (px) => Math.round(px * 0.75 * 100) / 100; 64 65 // 1 = catalog, 2 = page tree, then per page: page, content stream, image. 66 const pageIds = canvases.map((_, i) => 3 + i * 3); 67 write("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); 68 object(1, ["<< /Type /Catalog /Pages 2 0 R >>"]); 69 object(2, [`<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${canvases.length} >>`]); 70 for (let i = 0; i < canvases.length; i++) { 71 const canvas = canvases[i]; 72 const [pageId, contentId, imageId] = [pageIds[i], pageIds[i] + 1, pageIds[i] + 2]; 73 const w = pt(canvas.width); 74 const h = pt(canvas.height); 75 const jpeg = await PdfExportService.jpegBytes(canvas); 76 const content = `q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q`; 77 object(pageId, [ 78 `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] ` 79 + `/Resources << /XObject << /Im0 ${imageId} 0 R >> >> /Contents ${contentId} 0 R >>`, 80 ]); 81 object(contentId, [`<< /Length ${content.length} >>\nstream\n${content}\nendstream`]); 82 object(imageId, [ 83 `<< /Type /XObject /Subtype /Image /Width ${canvas.width} /Height ${canvas.height} ` 84 + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n`, 85 jpeg, 86 "\nendstream", 87 ]); 88 } 89 const count = 3 + canvases.length * 3; 90 const xref = length; 91 write(`xref\n0 ${count}\n0000000000 65535 f \n`); 92 for (let id = 1; id < count; id++) write(`${String(offsets[id]).padStart(10, "0")} 00000 n \n`); 93 write(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`); 94 return new Blob(chunks, { type: "application/pdf" }); 95 } 96 97 static async saveWithDialog() { 98 if (!app.lm) return; 99 const suggestedName = PdfExportService.suggestedFileName(app.lm); 100 let blob; 101 try {
102 const canvases = PdfExportService.pageCanvases(); 103 if (!canvases.length) return; 104 blob = await PdfExportService.buildPdf(canvases); 105 } catch (err) { 106 console.error("Failed to export PDF:", err); 107 return; 108 } 109 if (!("showSaveFilePicker" in window)) { 110 downloadBlobViaLink(blob, suggestedName); 111 await app.persistProjectState("saveAs"); 112 void saveProjectVersion("save"); 113 return; 114 } 115 try { 116 const handle = await window.showSaveFilePicker({ 117 suggestedName, 118 types: [{ description: "PDF document", accept: { "application/pdf": [".pdf"] } }], 119 }); 120 await writeBlobToFileHandle(handle, blob); 121 const chosenName = String(handle.name || "").trim() || suggestedName; 122 app.lm.lastSaveAsFileName = chosenName; 123 await app.persistProjectState("saveAs"); 124 void saveProjectVersion("save"); 125 } catch (err) { 126 if (err.name !== "AbortError") console.error("Failed to save PDF:", err); 127 } 128 } 129}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.