// Making a PDF where there wasn't one: photos in, or several files welded // into one. Both paths hand back plain bytes, which the editor then opens as // if they had come off disk — so everything downstream (marks, page rail, // save, convert) works on them without knowing where they came from. import { PDFDocument } from "../vendor/pdf-lib/pdf-lib.esm.min.js"; // Points, the PDF's own unit. A4 is the world's paper; Letter is North // America's. "fit" below means neither: the page takes the image's shape. export const PAGE_SIZES = { a4: [595.28, 841.89], letter: [612, 792], }; // The long edge of a fitted page. A phone photo is 4000 px on its longest // side; read as points that would be a 55-inch page, technically valid and // useless to anyone who prints it. Mapping the long edge to A4's long edge // keeps the image's proportions and gives a page a printer understands. const FIT_LONG_EDGE = 841.89; const JPEG = [0xff, 0xd8, 0xff]; const PNG = [0x89, 0x50, 0x4e, 0x47]; const starts = (bytes, sig) => sig.every((b, i) => bytes[i] === b); /** * pdf-lib embeds JPEG and PNG, and nothing else. Rather than refuse a WebP or * a HEIC-turned-WebP screenshot, hand it to the browser — which can decode far * more than pdf-lib can embed — and re-encode as PNG. Lossless, so a screenshot * survives intact; a photo re-encoded this way grows, which is why anything * already JPEG is passed through untouched. */ async function toEmbeddable(file) { const bytes = new Uint8Array(await file.arrayBuffer()); if (starts(bytes, JPEG)) return { bytes, kind: "jpg" }; if (starts(bytes, PNG)) return { bytes, kind: "png" }; let bitmap; try { bitmap = await createImageBitmap(file); } catch { throw new Error(`${file.name} is not an image this browser can read`); } const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); canvas.getContext("2d").drawImage(bitmap, 0, 0); bitmap.close(); const blob = await canvas.convertToBlob({ type: "image/png" }); return { bytes: new Uint8Array(await blob.arrayBuffer()), kind: "png" }; } /** * One image per page, in the order given. * * `page` is "fit" (the page takes each image's shape), "a4" or "letter". For * the two paper sizes the sheet turns landscape when the image is wider than * it is tall, because a landscape photo on a portrait page is mostly margin. * `margin` is in points and ignored by "fit" — the whole point of fitting the * page to the image is that there is no border. */ export async function imagesToPdf(files, { page = "fit", margin = 18, onProgress } = {}) { if (!files.length) throw new Error("no images given"); const pdf = await PDFDocument.create(); const failed = []; let n = 0; for (const file of files) { onProgress?.(++n, files.length, file.name); let img; try { const { bytes, kind } = await toEmbeddable(file); img = kind === "jpg" ? await pdf.embedJpg(bytes) : await pdf.embedPng(bytes); } catch (e) { failed.push({ name: file.name, why: e?.message || String(e) }); continue; } if (page === "fit") { const scale = FIT_LONG_EDGE / Math.max(img.width, img.height); const w = img.width * scale, h = img.height * scale; pdf.addPage([w, h]).drawImage(img, { x: 0, y: 0, width: w, height: h }); continue; } const [pw, ph] = PAGE_SIZES[page] || PAGE_SIZES.a4; const landscape = img.width > img.height; const sheet = pdf.addPage(landscape ? [ph, pw] : [pw, ph]); const boxW = sheet.getWidth() - margin * 2; const boxH = sheet.getHeight() - margin * 2; // contain, never crop: the smaller ratio wins, and the leftover becomes // an even border on the two sides that did not bind. const scale = Math.min(boxW / img.width, boxH / img.height); const w = img.width * scale, h = img.height * scale; sheet.drawImage(img, { x: (sheet.getWidth() - w) / 2, y: (sheet.getHeight() - h) / 2, width: w, height: h, }); } if (!pdf.getPageCount()) { throw new Error(failed.length ? `none of those files could be read (${failed[0].why})` : "no pages were made"); } return { bytes: await pdf.save(), pages: pdf.getPageCount(), failed }; } /** * Several PDFs, end to end, in the order given. * * Page content, annotations and links inside each document survive, because * copyPages carries the whole page object across. What does NOT survive is * anything that lives above the page: form fields, the outline, and * attachments. Those belong to the document they came from, and merging four * documents that each define a field called "name" has no correct answer. * The editor says so plainly rather than silently dropping them. */ export async function mergePdfs(files, { onProgress } = {}) { if (files.length < 2) throw new Error("pick at least two PDFs to merge"); const out = await PDFDocument.create(); const parts = []; const failed = []; let hadForms = false; let n = 0; for (const file of files) { onProgress?.(++n, files.length, file.name); try { const bytes = new Uint8Array(await file.arrayBuffer()); // An encrypted-but-openable PDF is the common case (print/copy flags, // no password). ignoreEncryption lets those through; a real // password-protected file still throws, and lands in `failed`. const src = await PDFDocument.load(bytes, { ignoreEncryption: true }); try { if (src.getForm().getFields().length) hadForms = true; } catch {} const copied = await out.copyPages(src, src.getPageIndices()); for (const p of copied) out.addPage(p); parts.push({ name: file.name, pages: copied.length }); } catch (e) { failed.push({ name: file.name, why: e?.message || String(e) }); } } if (!out.getPageCount()) { throw new Error(failed.length ? `nothing could be read (${failed[0].why})` : "no pages were copied"); } return { bytes: await out.save(), pages: out.getPageCount(), parts, failed, hadForms }; }