Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7716a56ff3432bc0c1e4837c
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/engine.ts
// Everything PDF does to files, on this device: open PDFs and images, draw page previews and
// build new PDFs. Nothing here touches the network. Loaded on first use to keep the landing light.
import { getDocument, GlobalWorkerOptions, PDFWorker, type PDFDocumentProxy, type PDFPageProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
import workerUrl from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url";
import {
PDFArray, PDFBool, PDFDict, PDFDocument, PDFName, PDFNull, PDFNumber, PDFRawStream, PDFRef, StandardFonts, EncryptedPDFError,
decodePDFRawStream, degrees, rgb, type PDFContext, type PDFImage, type PDFObject, type PDFPage,
} from "pdf-lib";
GlobalWorkerOptions.workerSrc = workerUrl;
// Decoders and character maps pdf.js fetches when a file needs them (copied by vite.config.ts).
const WASM_URL = import.meta.env.DEV ? "/node_modules/pdfjs-dist/wasm/" : "/pdfjs/";
const CMAP_URL = import.meta.env.DEV ? "/node_modules/pdfjs-dist/cmaps/" : "/pdfjs/cmaps/";
let worker: PDFWorker | null = null; // one pdf.js worker thread for every file, not one each
type PdfSource = { kind: "pdf"; doc: PDFDocument; view: PDFDocumentProxy | null };
type ImageSource = { kind: "image"; file: File; img: HTMLImageElement };
const sources = new Map<string, PdfSource | ImageSource>();
/** A problem worded for the person, safe to show as it is. */
export class FileProblem extends Error {}
export type Opened = { kind: "pdf" | "image"; pages: number };
export type NumberPos = "bottom-center" | "bottom-right" | "bottom-left" | "top-center" | "top-right" | "top-left";
export type PageSize = "a4" | "letter" | "fit";
export type Spec = { src: string; index: number; rotate: number };
export type Shrink = "off" | "medium" | "strong";
export type Output = { numbers: NumberPos | null; numberStyle: "plain" | "of"; imageSize: PageSize; shrink: Shrink };
/** Reports a stage of the work: what is happening, and how far along it is. */
export type Progress = (label: string, done: number, total: number) => void;
const IMAGE_NAME = /\.(jpe?g|png|webp|gif|bmp|avif|heic|heif)$/i;
/** Opens a PDF or an image and keeps it under `id`. Throws FileProblem with a plain explanation. */
export async function open(id: string, file: File): Promise<Opened> {
const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
const looksPdf = new TextDecoder("latin1").decode(head).includes("%PDF-");
if (looksPdf || file.type === "application/pdf" || /\.pdf$/i.test(file.name)) return openPdf(id, file);
if (file.type.startsWith("image/") || IMAGE_NAME.test(file.name)) return openImage(id, file);
throw new FileProblem(`${file.name} is not a PDF or an image, so it was left out.`);
}
async function openPdf(id: string, file: File): Promise<Opened> {
const bytes = new Uint8Array(await file.arrayBuffer());
let doc: PDFDocument;
let pages: number;
try {
doc = await PDFDocument.load(bytes, { updateMetadata: false });
pages = doc.getPageCount();
} catch (err) {
// pdf-lib can stumble on an encrypted file before it notices the encryption, so look for it too.
if (err instanceof EncryptedPDFError || new TextDecoder("latin1").decode(bytes).includes("/Encrypt")) throw new FileProblem(locked(file.name));
throw new FileProblem(`${file.name} could not be read. It may be damaged, or it may not really be a PDF.`);
}
if (!pages) throw new FileProblem(`${file.name} has no pages.`);
const source: PdfSource = { kind: "pdf", doc, view: null };
// Previews come from pdf.js; a file it cannot draw still works, it just shows no preview.
try {
worker ??= new PDFWorker();
source.view = await getDocument({ data: bytes.slice(), wasmUrl: WASM_URL, cMapUrl: CMAP_URL, worker }).promise;
} catch (err) {
if ((err as Error)?.name === "PasswordException") throw new FileProblem(locked(file.name));
}
sources.set(id, source);
return { kind: "pdf", pages };
}
function locked(name: string) {
return `${name} is locked (it has a password or restrictions), so it cannot be changed here. Open it in a PDF reader, save or print it as a new PDF, then add that copy.`;
}
async function openImage(id: string, file: File): Promise<Opened> {
const img = new Image();
img.src = URL.createObjectURL(file);
try {
await img.decode();
} catch {
URL.revokeObjectURL(img.src);
if (/\.(heic|heif)$/i.test(file.name) || /hei[cf]/i.test(file.type)) {
throw new FileProblem(`This browser cannot open HEIC photos like ${file.name}. Safari on iPhone and Mac can, or save the photo as JPG and add it again.`);
}
throw new FileProblem(`${file.name} could not be opened as an image.`);
}
sources.set(id, { kind: "image", file, img });
return { kind: "image", pages: 1 };
}
/** Forgets every file and frees its memory. */
export function reset() {
for (const s of sources.values()) {
if (s.kind === "pdf") void s.view?.loadingTask.destroy();
else URL.revokeObjectURL(s.img.src);
}
sources.clear();
for (const p of thumbs.values()) p.then((u) => URL.revokeObjectURL(u), () => {});
thumbs.clear();
pending.length = 0;
drawn.length = 0;
}
// Previews: drawn two at a time, newest request first, since that is the part of the page in view.
// pdf.js page memory is freed only once nothing is drawing: freeing it mid-draw blanks fonts on other pages.
const THUMB_PX = 360;
const thumbs = new Map<string, Promise<string>>();
type Job = { run: () => Promise<string>; done: (u: string) => void; fail: (e: unknown) => void };
const pending: Job[] = [];
const drawn: PDFPageProxy[] = [];
let active = 0;
function pump() {
while (active < 2 && pending.length) {
const job = pending.pop()!;
void drawing(job.run).then(job.done, job.fail);
}
}
/** Runs one pdf.js drawing job, and frees page memory once nothing is drawing. */
async function drawing<T>(run: () => Promise<T>): Promise<T> {
active++;
try {
return await run();
} finally {
active--;
if (!active && !pending.length) for (const page of drawn.splice(0)) page.cleanup();
pump();
}
}
/** A small preview image (object URL) of one page, as the source file shows it. */
export function thumbnail(id: string, index: number): Promise<string> {
const key = `${id}:${index}`;
let p = thumbs.get(key);
if (!p) {
p = new Promise<string>((done, fail) => {
pending.push({ run: () => drawThumb(id, index), done, fail });
pump();
});
thumbs.set(key, p);
}
return p;
}
async function drawThumb(id: string, index: number): Promise<string> {
const src = sources.get(id);
if (!src) throw new Error("gone");
const canvas = document.createElement("canvas");
if (src.kind === "image") {
const { naturalWidth: w, naturalHeight: h } = src.img;
const k = Math.min(1, THUMB_PX / Math.max(w, h));
canvas.width = Math.max(1, Math.round(w * k));
canvas.height = Math.max(1, Math.round(h * k));
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(src.img, 0, 0, canvas.width, canvas.height);
} else {
if (!src.view) throw new Error("no preview");
const page = await src.view.getPage(index + 1);
const base = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: THUMB_PX / Math.max(base.width, base.height) });
canvas.width = Math.max(1, Math.round(viewport.width));
canvas.height = Math.max(1, Math.round(viewport.height));
drawn.push(page);
await page.render({ canvas, viewport }).promise;
}
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", 0.85));
if (!blob) throw new Error("no preview");
return URL.createObjectURL(blob);
}
/** A finished PDF, and how many bytes compressing took off it. */
export type Built = { bytes: Uint8Array; shrunk: number };
/** Builds one PDF from pages in order. */
export async function build(pages: Spec[], out: Output, progress?: Progress): Promise<Built> {
const doc = await PDFDocument.create();
doc.setCreator("PDF by OpenApps");
// Copy each file's pages in one call, so fonts and images they share are stored once.
const copied = new Map<string, PDFPage>();
const bySource = new Map<string, number[]>();
for (const p of pages) bySource.set(p.src, [...(bySource.get(p.src) || []), p.index]);
for (const [id, indices] of bySource) {
const src = sources.get(id);
if (src?.kind !== "pdf") continue;
const got = await doc.copyPages(src.doc, indices);
indices.forEach((i, k) => copied.set(`${id}:${i}`, got[k]));
}
for (const [n, p] of pages.entries()) {
progress?.("Adding pages", n, pages.length);
const src = sources.get(p.src);
if (!src) throw new FileProblem("A file was removed while the PDF was being made. Try again.");
let page: PDFPage;
if (src.kind === "pdf") {
page = doc.addPage(copied.get(`${p.src}:${p.index}`)!);
page.setRotation(degrees(turn(page.getRotation().angle + p.rotate)));
} else {
page = await imagePage(doc, src, out.imageSize);
page.setRotation(degrees(turn(p.rotate)));
}
if (n % 20 === 19) await new Promise((r) => setTimeout(r));
}
if (out.numbers) await addNumbers(doc, out.numbers, out.numberStyle);
let shrunk = 0;
if (out.shrink !== "off") {
await doc.flush(); // images added from JPG and PNG files join the file here, so they shrink too
shrunk = await shrinkImages(doc.context, out.shrink, progress);
}
progress?.("Saving", 1, 1);
return { bytes: await doc.save({ objectsPerTick: 200 }), shrunk };
}
/** Normalizes any angle to 0, 90, 180 or 270. */
function turn(angle: number): number {
return ((Math.round(angle / 90) * 90) % 360 + 360) % 360;
}
const PAPER: Record<"a4" | "letter", [number, number]> = { a4: [595.28, 841.89], letter: [612, 792] };
async function imagePage(doc: PDFDocument, src: ImageSource, size: PageSize): Promise<PDFPage> {
const image = await embedImage(doc, src);
// Image pixels at 96 per inch; a PDF page can be at most 200 inches on a side.
let w = src.img.naturalWidth * 0.75;
let h = src.img.naturalHeight * 0.75;
if (size === "fit") {
const k = Math.min(1, 14400 / Math.max(w, h));
const page = doc.addPage([w * k, h * k]);
page.drawImage(image, { x: 0, y: 0, width: w * k, height: h * k });
return page;
}
let [pw, ph] = PAPER[size];
if (w > h) [pw, ph] = [ph, pw];
const k = Math.min(pw / w, ph / h);
w *= k;
h *= k;
const page = doc.addPage([pw, ph]);
page.drawImage(image, { x: (pw - w) / 2, y: (ph - h) / 2, width: w, height: h });
return page;
}
async function embedImage(doc: PDFDocument, src: ImageSource): Promise<PDFImage> {
const bytes = new Uint8Array(await src.file.arrayBuffer());
try {
// JPEG and PNG go in as they are, so there is no loss. A JPEG that only looks upright
// because of its camera rotation tag is redrawn below, the way the browser shows it.
if (bytes[0] === 0xff && bytes[1] === 0xd8 && jpegOrientation(bytes) === 1) return await doc.embedJpg(bytes);
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return await doc.embedPng(bytes);
} catch {
// An unusual encoding pdf-lib cannot read: fall through and redraw it.
}
const { naturalWidth: w, naturalHeight: h } = src.img;
const k = Math.min(1, Math.sqrt(16_000_000 / (w * h))); // phone browsers refuse larger canvases
const canvas = document.createElement("canvas");
canvas.width = Math.round(w * k);
canvas.height = Math.round(h * k);
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(src.img, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", 0.92));
if (!blob) throw new FileProblem(`${src.file.name} could not be added. Try saving it as JPG first.`);
return doc.embedJpg(new Uint8Array(await blob.arrayBuffer()));
}
/** The EXIF orientation of a JPEG (1 = upright), read from its APP1 segment. */
function jpegOrientation(b: Uint8Array): number {
let p = 2;
while (p + 4 < b.length && b[p] === 0xff) {
const marker = b[p + 1];
const len = (b[p + 2] << 8) | b[p + 3];
if (marker === 0xda) break;
if (marker === 0xe1 && String.fromCharCode(...b.subarray(p + 4, p + 8)) === "Exif") {
const t = p + 10;
const le = b[t] === 0x49;
const u16 = (o: number) => (le ? b[o] | (b[o + 1] << 8) : (b[o] << 8) | b[o + 1]);
const u32 = (o: number) => (le ? u16(o) + u16(o + 2) * 65536 : u16(o) * 65536 + u16(o + 2));
const ifd = t + u32(t + 4);
const count = u16(ifd);
for (let i = 0; i < count; i++) {
const e = ifd + 2 + i * 12;
if (u16(e) === 0x0112) return u16(e + 8) || 1;
}
break;
}
p += 2 + len;
}
return 1;
}
/** Writes page numbers where a reader sees them, on pages turned any way. */
async function addNumbers(doc: PDFDocument, pos: NumberPos, style: Output["numberStyle"]) {
const font = await doc.embedFont(StandardFonts.Helvetica);
const pages = doc.getPages();
pages.forEach((page, i) => {
const text = style === "of" ? `${i + 1} of ${pages.length}` : String(i + 1);
const box = page.getCropBox();
const r = turn(page.getRotation().angle);
const [vw, vh] = r % 180 ? [box.height, box.width] : [box.width, box.height];
const size = Math.max(8, Math.min(14, Math.min(vw, vh) * 0.018));
const margin = Math.max(14, Math.min(40, Math.min(vw, vh) * 0.045));
const tw = font.widthOfTextAtSize(text, size);
// (vx, vy): where the text starts as the page is seen; (x, y): the same point on the unturned page.
const vx = pos.endsWith("left") ? margin : pos.endsWith("right") ? vw - margin - tw : (vw - tw) / 2;
const vy = pos.startsWith("top") ? vh - margin - size * 0.72 : margin;
const [x, y] =
r === 90 ? [box.width - vy, vx] : r === 180 ? [box.width - vx, box.height - vy] : r === 270 ? [vy, box.height - vx] : [vx, vy];
page.drawText(text, { x: box.x + x, y: box.y + y, size, font, color: rgb(0.2, 0.2, 0.22), rotate: degrees(r) });
});
}
// Compressing: photos and scans are the heavy part of most PDFs. Each one is redrawn smaller and saved
// as JPEG; text, fonts and drawings are not touched, so they stay sharp. An image is replaced only
// when that makes it clearly smaller, and kinds a browser cannot redraw faithfully are left as they are.
const LEVELS: Record<Exclude<Shrink, "off">, { px: number; quality: number }> = {
medium: { px: 2000, quality: 0.72 },
strong: { px: 1300, quality: 0.55 },
};
const name = (n: string) => PDFName.of(n);
const GENERIC = new Set(["FlateDecode", "LZWDecode", "ASCII85Decode", "ASCIIHexDecode", "RunLengthDecode"]);
/** Shrinks the images in a file and returns how many bytes that saved. */
async function shrinkImages(ctx: PDFContext, level: Exclude<Shrink, "off">, progress?: Progress): Promise<number> {
const masks = new Set<PDFRef>(); // transparency masks stay exact
const images: [PDFRef, PDFRawStream][] = [];
for (const [ref, obj] of ctx.enumerateIndirectObjects()) {
if (!(obj instanceof PDFRawStream) || obj.dict.get(name("Subtype")) !== name("Image")) continue;
for (const key of ["SMask", "Mask"]) {
const m = obj.dict.get(name(key));
if (m instanceof PDFRef) masks.add(m);
}
images.push([ref, obj]);
}
let saved = 0;
for (const [n, [ref, image]] of images.entries()) {
progress?.("Compressing images", n, images.length);
if (masks.has(ref)) continue;
const smaller = await shrinkImage(ctx, image, LEVELS[level]).catch(() => null);
if (!smaller) continue;
ctx.assign(ref, smaller);
saved += image.contents.length - smaller.contents.length;
}
return saved;
}
async function shrinkImage(ctx: PDFContext, image: PDFRawStream, { px, quality }: { px: number; quality: number }): Promise<PDFRawStream | null> {
const dict = image.dict;
const w = numberIn(dict, "Width");
const h = numberIn(dict, "Height");
const size = image.contents.length;
if (size < 30_000 || !w || !h || w * h > 40_000_000) return null;
if (dict.lookup(name("ImageMask")) === PDFBool.True || dict.has(name("Decode")) || dict.lookup(name("Mask")) instanceof PDFArray) return null;
if (numberIn(dict, "BitsPerComponent") !== 8) return null;
const channels = colorChannels(dict.lookup(name("ColorSpace")));
if (!channels) return null;
// Filters run in order: general ones (like ASCII85 or Flate) may wrap the image's own encoding.
const f = dict.lookup(name("Filter"));
const filters = f instanceof PDFName ? [f] : f instanceof PDFArray ? f.asArray().map((x) => ctx.lookup(x)) : [];
const params = dict.lookup(name("DecodeParms"));
if (!filters.length || filters.some((x) => !(x instanceof PDFName) || !(GENERIC.has(x.decodeText()) || x === name("DCTDecode")))) return null;
if (filters.length > 1 && params && !(params instanceof PDFArray)) return null;
const last = filters.length - 1;
const paramsOf = (i: number) => (params instanceof PDFArray ? params.lookup(i) : params);
let picture: ImageBitmap;
if (filters[last] === name("DCTDecode")) {
if (filters.slice(0, last).some((x) => x === name("DCTDecode")) || (paramsOf(last) && paramsOf(last) !== PDFNull)) return null;
let jpeg = image.contents;
if (last) {
const outer = PDFDict.withContext(ctx);
outer.set(name("Filter"), ctx.obj(filters.slice(0, last)));
if (params instanceof PDFArray) outer.set(name("DecodeParms"), ctx.obj(params.asArray().slice(0, last)));
jpeg = decodePDFRawStream(PDFRawStream.of(outer, image.contents)).decode();
}
if (jpegOrientation(jpeg) !== 1) return null;
picture = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" }));
} else {
const pixels = unpredict(decodePDFRawStream(image).decode(), paramsOf(last), w, h, channels);
if (!pixels) return null;
const rgba = new Uint8ClampedArray(w * h * 4);
for (let i = 0, j = 0; i < w * h; i++, j += channels) {
rgba[i * 4] = pixels[j];
rgba[i * 4 + 1] = pixels[j + (channels === 3 ? 1 : 0)];
rgba[i * 4 + 2] = pixels[j + (channels === 3 ? 2 : 0)];
rgba[i * 4 + 3] = 255;
}
picture = await createImageBitmap(new ImageData(rgba, w, h));
}
if (picture.width !== w || picture.height !== h) {
picture.close();
return null;
}
const k = Math.min(1, px / Math.max(w, h));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w * k));
canvas.height = Math.max(1, Math.round(h * k));
const draw = canvas.getContext("2d")!;
draw.imageSmoothingQuality = "high";
draw.drawImage(picture, 0, 0, canvas.width, canvas.height);
picture.close();
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", quality));
canvas.width = canvas.height = 0;
if (!blob || blob.size > size * 0.9) return null;
const next = dict.clone(ctx);
next.set(name("Filter"), name("DCTDecode"));
next.delete(name("DecodeParms"));
next.set(name("Width"), PDFNumber.of(Math.max(1, Math.round(w * k))));
next.set(name("Height"), PDFNumber.of(Math.max(1, Math.round(h * k))));
if (channels === 1) next.set(name("ColorSpace"), name("DeviceRGB")); // a browser saves every JPEG in color
return PDFRawStream.of(next, new Uint8Array(await blob.arrayBuffer()));
}
function numberIn(dict: PDFDict, key: string, fallback = 0): number {
const v = dict.lookup(name(key));
return v instanceof PDFNumber ? v.asNumber() : fallback;
}
/** 3 for RGB, 1 for gray, 0 for color spaces a browser cannot redraw as they are (CMYK, spot colors, palettes). */
function colorChannels(cs: PDFObject | undefined): number {
if (cs === name("DeviceRGB")) return 3;
if (cs === name("DeviceGray")) return 1;
if (!(cs instanceof PDFArray)) return 0;
const kind = cs.lookup(0);
if (kind === name("CalRGB")) return 3;
if (kind === name("CalGray")) return 1;
if (kind !== name("ICCBased")) return 0;
const profile = cs.lookup(1);
const n = profile instanceof PDFRawStream ? numberIn(profile.dict, "N") : 0;
return n === 3 || n === 1 ? n : 0;
}
/** Undoes PNG row filters (Predictor 10 to 15), which many PDF writers apply before compressing images. */
function unpredict(data: Uint8Array, parms: PDFObject | undefined, w: number, h: number, channels: number): Uint8Array | null {
const row = w * channels;
const predictor = parms instanceof PDFDict ? numberIn(parms, "Predictor", 1) : 1;
if (predictor === 1) return data.length >= row * h ? data : null;
if (predictor < 10 || !(parms instanceof PDFDict)) return null;
if (numberIn(parms, "Colors", 1) !== channels || numberIn(parms, "BitsPerComponent", 8) !== 8 || numberIn(parms, "Columns", 1) !== w) return null;
if (data.length < (row + 1) * h) return null;
const out = new Uint8Array(row * h);
let prev = new Uint8Array(row);
for (let y = 0, p = 0; y < h; y++, p += row) {
const type = data[p++];
const line = out.subarray(y * row, (y + 1) * row);
for (let x = 0; x < row; x++) {
const a = x >= channels ? line[x - channels] : 0;
const b = prev[x];
const c = x >= channels ? prev[x - channels] : 0;
const guess = type === 1 ? a : type === 2 ? b : type === 3 ? (a + b) >> 1 : type === 4 ? paeth(a, b, c) : 0;
line[x] = (data[p + x] + guess) & 255;
}
prev = line;
}
return out;
}
function paeth(a: number, b: number, c: number): number {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
}
/** One page as a JPG or PNG image, turned the way the workspace shows it. */
export function pageImage(p: Spec, type: "image/jpeg" | "image/png", dpi: number): Promise<Blob> {
return drawing(async () => {
const src = sources.get(p.src);
if (!src) throw new FileProblem("A file was removed while the images were being made. Try again.");
const canvas = document.createElement("canvas");
const turned = turn(p.rotate);
if (src.kind === "image") {
const { naturalWidth: w, naturalHeight: h } = src.img;
const k = Math.min(1, Math.sqrt(16_000_000 / (w * h))); // phone browsers refuse larger canvases
const [cw, ch] = turned % 180 ? [h * k, w * k] : [w * k, h * k];
canvas.width = Math.round(cw);
canvas.height = Math.round(ch);
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((turned * Math.PI) / 180);
ctx.drawImage(src.img, (-w * k) / 2, (-h * k) / 2, w * k, h * k);
} else {
if (!src.view) throw new FileProblem(`Page ${p.index + 1} of a file could not be drawn, so no image was made.`);
const page = await src.view.getPage(p.index + 1);
const rotation = turn(page.rotate + p.rotate);
const base = page.getViewport({ scale: 1, rotation });
const scale = Math.min(dpi / 72, Math.sqrt(16_000_000 / (base.width * base.height)));
const viewport = page.getViewport({ scale, rotation });
canvas.width = Math.max(1, Math.round(viewport.width));
canvas.height = Math.max(1, Math.round(viewport.height));
drawn.push(page);
await page.render({ canvas, viewport }).promise;
}
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, type, 0.9));
canvas.width = canvas.height = 0;
if (!blob) throw new FileProblem("This browser could not save the image. Try JPG, or a smaller resolution.");
return blob;
});
}
Change history
Quality pass: compress, PDF to JPG, phone layout, job links, llms.txt and index.md
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync