PDFs
runtime:pdf opens, reads, edits and writes PDFs. Work on bytes needs no capability.
Extract text
import { PDF } from "runtime:pdf"; const doc = await PDF.open(bytes); const text = await doc.text(); // every page, separated by "\f" const pages = text.split("\f");
For positions, read one page at a time: pageText(i) gives lines and words with their boxes in points, y up from the bottom of the page.
const { lines } = await doc.pageText(0); for (const line of lines) console.log(line.bbox, line.text);
Merge and split
Edits start from doc.edit() and every method returns a new edit, so one document can be cut several ways:
import { PDF } from "runtime:pdf"; import { file } from "runtime:fs"; const a = await PDF.open(file("a.pdf")); const b = await PDF.open(file("b.pdf")); await a.edit().append(b).write(file("merged.pdf")); await a.edit().select([0]).write(file("cover.pdf")); await a.edit().delete([0]).write(file("body.pdf"));
append(b, [0, 2]) takes only some of b's pages; select([2, 0]) reorders. To read the result, open it: await PDF.open(await edit.bytes()).
Untrusted uploads
Every file is treated as hostile: a damaged one is repaired and the repair reported in doc.diagnostics, and one stream may decode to at most 64 MiB unless maxDecodedSize says otherwise. Bound the time with a signal, strip active content before storing, and map the errors to responses:
import { serve } from "runtime:http"; import { PDF } from "runtime:pdf"; serve({ port: 8080 }, async (request) => { try { const doc = await PDF.open(await request.bytes(), { signal: AbortSignal.timeout(5_000) }); const clean = await doc.edit().sanitize().bytes({ encrypt: false }); return new Response(clean, { headers: { "content-type": "application/pdf" } }); } catch (error) { if (error.code === "ERR_PDF_INVALID_PASSWORD") return new Response(null, { status: 422 }); if (error.code === "ERR_PDF_LIMIT") return new Response(null, { status: 413 }); if (error.code === "ERR_PDF_MALFORMED") return new Response(null, { status: 400 }); if (error.name === "TimeoutError") return new Response(null, { status: 408 }); throw error; } });
sanitize() removes JavaScript, launch and submit actions, attachments, XFA forms and multimedia. sanitize({ links: true, metadata: true }) removes links and document metadata as well.
To log or reject instead of stripping, count first; sanitizeReport changes nothing:
const found = await doc.sanitizeReport(); if (found.javascript > 0) console.warn("upload carried scripts", found);
encrypt: false is there because an upload may be encrypted: a full save of an encrypted file has to say whether to keep the encryption, so a password is never removed without the code saying so.
Encrypt
const locked = await doc.edit().bytes({ encrypt: { ownerPassword: ownerSecret, userPassword: "", // opens without a password… permissions: { copy: false, modify: false }, // …but these are refused }, });
Encryption is AES-256. Opening an encrypted file takes { password }; the user or the owner password works.
Metadata and attachments
const out = await doc .edit() .setInfo({ title: "Q3 report", author: "Finance", modDate: new Date() }) .addAttachment("data.csv", csvBytes, { type: "text/csv" }) .bytes();
doc.info reads the same fields back, with dates as Dates; doc.attachments() lists embedded files and doc.attachment(name) returns one's bytes.