open - the door

truecopy/open

The only way from bytes to rows, with the caps, the deadline and the engine released. And a refusal your application can say in its own language.

import { openDocument, UnreadableDocument, DEFAULT_LIMITS } from 'truecopy/open';

const document = await openDocument(file, options?);

What lives here forces, and it forces for a reason no interface can match: there is no other route in.

file is whatever was dropped. A PDF goes through pdf.js; anything else is read as text: a CSV, a TSV, a table pasted out of a viewer, an OCR dump. The text path loads no engine at all, and it picks its own ruler: see documentFromText.

Default Why it exists
maximumBytes 20 MB past this it is not the document expected: refuse rather than freeze the tab
maximumPages 40 a document with thousands is not a statement
deadlineMilliseconds 30 000 a read that never returns leaves a screen with no button and no way out

No byte leaves the process.

The refusal is named, so the sentence is yours

try {
  await openDocument(file);
} catch (error) {
  if (error instanceof UnreadableDocument) {
    // 'empty' | 'too-big' | 'no-text' | 'too-slow' | 'not-opened'
    showInYourOwnWords(error.reason);
  }
}

The library used to ship English sentences and nothing else, which broke its own rule: it names the rule that broke, your application writes the sentence in its own voice. An application speaking anything but English had to keep a copy of this whole file to say “this file is over 20 MB”.

reason is for the code; message is there for whoever has no application to write the sentence for them.

The pdf.js worker

// Vite
import workerSrc from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
await openDocument(file, { workerSrc });

In a browser this is required: pdf.js refuses to start without it. In Node you can leave it out and pdf.js runs inline: slower on a long document, correct everywhere, and it is what makes the whole chain testable outside a browser.

truecopy deliberately does not resolve that URL. Vite wants ?url, webpack wants new URL(…, import.meta.url), a plain page wants a path it can serve. Picking one would lock every caller into that bundler for the sake of one line.

Bringing your own engine

import * as pdfjs from 'pdfjs-dist'; // the modern build
await openDocument(file, { pdfjs });

The default is the legacy build, which runs in Node and keeps the chain from real bytes to a parsed row testable. The modern one is smaller by over a hundred kilobytes brotli, which decides it for anything under a byte budget. Neither is right for both, so neither is chosen for you.

PdfEngine is structural: both builds satisfy it without knowing the type exists, and so would another engine.

Also here

  • withDeadline(promise, ms): bound any read in time. The timer is always cleared, even when the read wins the race: a forgotten timer keeps the process awake and fails tests long after they passed.
  • positionedItems(items): the one pure step between a PDF engine and this library’s data, exported so a test driving a real engine over real bytes can prove the chain joins up.

Everything that turns those items into rows and columns lives in layout, which needs no engine, no bytes and no clock.