pdf-free.openapps.xyz

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.
Owned app · rev 0341fee61377
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.
← All files
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
0341fee613 · 6 hours ago
Bounded source sync
f00de5deb0 · 1 day ago
Bounded source sync
3c8124e576 · 1 day ago
Bounded source sync
5c14663e0f · 1 day ago
Bounded source sync
ee5c852f40 · 1 day ago