Clicking the dock raised a native file browser, which was the right answer while the editor had exactly one thing to offer an empty tab. It is the wrong answer now: a file browser can only ask which PDF, and the answer is sometimes none of them. So the dock opens the editor, and the empty editor says what it can do. The drop zone stays, and learns to read what it is given — pictures become pages, several PDFs become one document. Beside it sit the three ways in as buttons. Not included: compress, which cannot be done honestly without re-encoding the images, and split, which is the page rail plus Save a copy. A document built from pictures or joins has never been on disk, so it is marked unsaved from the moment it opens — otherwise closing the tab would bin it without asking. An empty editor also stops claiming to hold a file called document.pdf.
133 lines
5.9 KiB
JavaScript
133 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 };
|
|
}
|