134 lines
5.9 KiB
JavaScript
134 lines
5.9 KiB
JavaScript
|
|
// 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 };
|
||
|
|
}
|