theseus/bundled-addons/pdf-editor/lib/create.js

134 lines
5.9 KiB
JavaScript
Raw Normal View History

// 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 };
}