// LIFTING THE TEXT OUT OF A PDF, IN THE BROWSER, so a practice does not retype its own agreements. // // WHY THE BROWSER. Spencer's framing and the privacy property point the same way: the document never // leaves the practice's machine. Uploading it would make us the custodian of a legal document we have // no reason to hold, and would need a bucket, a retention rule and an answer about what happens to it // when the agreement is replaced. // // WHAT THIS IS NOT, AND THE DISTINCTION IS THE WHOLE DESIGN. The PDF is an INPUT, never storage. What // gets stored, versioned and pinned to a Consent is OUR OWN TEXT - the same string as if it had been // typed. A PDF as storage would have to be rendered inside the agree flow, versioned, and retained // after replacement so a March signature still resolves; a PDF as input has none of those problems. // // EXTRACTION LOSES LAYOUT, AND THAT IS WHY IT NEVER SAVES ITSELF. Headers land in the body, columns // read across instead of down, and text in sidebars, boxes or tables can arrive out of order or not at // all. A clause that silently fails to extract produces an agreement MISSING A CLAUSE, which patients // then agree to - and nothing downstream can catch it, because the version, the signature and the audit // trail are all valid records of the wrong document. So this function's contract is: hand back text for // a human to read. It has no access to a save. /** What a caller gets back. `text` is never empty on success - see `NoTextLayerError`. */ export interface ExtractedPdfText { text: string; pageCount: number; } /** * A PDF that parsed fine and contains no text at all. * * ITS OWN ERROR TYPE, because it is the one failure a practice can DO something about, and the advice * is specific: this is a scan, so it is an image of a document rather than a document. Collapsing it * into a generic "could not read the file" would send somebody to check the file is not corrupt. * * AND IT MUST NEVER RESOLVE AS EMPTY TEXT. An empty string reaching the editor is * indistinguishable from a practice clearing the box - which is the gesture that REVERTS the agreement * to the platform default. A scan would silently discard a customised agreement. */ export class NoTextLayerError extends Error { constructor() { super('This PDF has no text in it - it looks like a scan or a picture of a document.'); this.name = 'NoTextLayerError'; } } /** * Pulls the text out of a PDF file. * * LAZY, AND THAT IS DELIBERATE. `pdfjs-dist` is not small and the plugin already ships several MiB of * JS. Authoring an agreement is a rare administrative action, so the parser is fetched on the first * upload and never by a patient who will not invoke it. A static import would put it in the main * bundle for everybody. */ export async function extractPdfText(file: File): Promise { const pdfjs = await import('pdfjs-dist'); // The worker is resolved through the bundler rather than a CDN: a practice's browser must not need // to reach a third-party host to read a document that never leaves their machine. pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, ).toString(); const data = new Uint8Array(await file.arrayBuffer()); const document = await pdfjs.getDocument({ data }).promise; const pages: string[] = []; for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { const page = await document.getPage(pageNumber); const content = await page.getTextContent(); // Items carry an `str` and a flag for whether a line ended after them. Using the flag rather // than joining on spaces is what keeps paragraphs from running together into one line - the // difference between text a human can proof-read and a wall they will skim. const pageText = content.items .map((item) => { const textItem = item as { str?: unknown; hasEOL?: unknown }; const str = typeof textItem.str === 'string' ? textItem.str : ''; return textItem.hasEOL === true ? `${str}\n` : str; }) .join(''); pages.push(pageText); } // Pages joined by a blank line. A page break is not a paragraph break, but it is closer to one than // nothing, and a reader correcting the text can see where one page ended. const text = pages.join('\n\n').trim(); if (text.length === 0) { // The scanned case. Thrown rather than returned so a caller cannot forget to check it, and so // an empty string can never reach the editor. throw new NoTextLayerError(); } return { text, pageCount: document.numPages }; }