Move the whole pipeline into the browser, drop the server
Every stage now runs client-side: pdf.js renders, a per-pixel diff finds the hidden ink, tesseract.js transcribes it on request, and pdf-lib rebuilds the flattened export. The PDF goes from the file picker into memory and back out as a download, and is never sent anywhere. That makes the privacy claim structural rather than a promise. The server is left with one route — a health check — so there is no code path that can receive a document, nothing is persisted, and no account exists. The OCR model and WASM are staged out of node_modules into public/ at build time so the page never reaches a CDN either. Verified in the browser: a full scan with OCR contacts exactly one origin, its own, and POST /api/upload returns 404 in the built image. Contrast stretching turned out to be the wrong method entirely. Text painted in pure #FFFFFF rasterizes to bytes identical to blank paper, so there is no difference in the image to amplify — and a page with black body text already spans 0-255, so the old server-side normalize() was a no-op. It could never have found the case it advertised. So each page is rendered twice, on white and on black. White ink vanishes into the first and blazes in the second; inverting the black-backed render and taking the darker pixel of each pair puts every mark on the page in view whatever colour it was drawn in. Confirmed against a PDF carrying a genuine white-on-white payload: invisible in "as delivered", flagged in vermilion in "revealed", and transcribed verbatim by OCR. Pages that paint their own opaque background defeat the technique, so that case is detected and reported as unreliable instead of showing a page-sized false positive. OCR is opt-in, as asked: it costs an ~8MB model download and runs at a few seconds a page, so nothing is fetched until the box is ticked. Consequences: - Removed auth, Prisma, Postgres, the upload/render/pixel/ocr/export routes, the storage layer and the signed-in dashboard. - Runtime dependencies drop from 13 to 6, all browser-capable; sharp, @napi-rs/canvas, next-auth, pg and the Prisma stack are all gone. - compose is a single stateless service with no volumes and no config, the entrypoint and its migration step are gone, and the image drops from 868MB to 455MB. - The landing page no longer describes the contrast method it used to claim, since that method provably does not work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ca71853eb
commit
c6a564ec5d
44 files changed
+1142
-4654
No files matched your search
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copies the WASM/worker assets the browser pipeline needs out of node_modules
|
||||
* and into public/, so the app serves them itself.
|
||||
*
|
||||
* This is not a bundling convenience — it is what makes the privacy claim true.
|
||||
* Left to its own devices, tesseract.js fetches its core and language data from
|
||||
* a CDN at runtime, which would mean the page reaches out to a third party the
|
||||
* moment someone runs OCR. Serving them from our own origin keeps the whole
|
||||
* pipeline on the user's machine and our own domain.
|
||||
*
|
||||
* Runs from `predev` and `prebuild`, so the files are in place before Next
|
||||
* looks at public/. The copied files are gitignored: they are build outputs
|
||||
* derived from the lockfile, not sources.
|
||||
*/
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const publicDir = path.join(root, "public");
|
||||
|
||||
/** Resolve a file inside an installed package without hardcoding node_modules. */
|
||||
function pkgFile(pkg, ...segments) {
|
||||
return path.join(path.dirname(require.resolve(`${pkg}/package.json`)), ...segments);
|
||||
}
|
||||
|
||||
const COPIES = [
|
||||
// pdf.js renders pages off the main thread.
|
||||
{
|
||||
from: pkgFile("pdfjs-dist", "build", "pdf.worker.min.mjs"),
|
||||
to: path.join(publicDir, "pdfjs", "pdf.worker.min.mjs"),
|
||||
},
|
||||
// tesseract.js worker shim.
|
||||
{
|
||||
from: pkgFile("tesseract.js", "dist", "worker.min.js"),
|
||||
to: path.join(publicDir, "tesseract", "worker.min.js"),
|
||||
},
|
||||
];
|
||||
|
||||
// All three LSTM core builds ship. tesseract.js probes the browser at runtime
|
||||
// and picks relaxed-SIMD, SIMD or plain, so serving a subset breaks OCR on
|
||||
// whichever browsers wanted the missing one. Each variant needs the .wasm
|
||||
// alongside its .wasm.js glue — the glue is what actually gets importScripts'd,
|
||||
// and shipping the binary without it fails at load.
|
||||
//
|
||||
// This is ~20MB on disk, but a given browser downloads only the one variant it
|
||||
// selected (~6.5MB), and only if someone turns OCR on.
|
||||
for (const variant of [
|
||||
"tesseract-core-lstm",
|
||||
"tesseract-core-simd-lstm",
|
||||
"tesseract-core-relaxedsimd-lstm",
|
||||
]) {
|
||||
for (const ext of ["js", "wasm", "wasm.js"]) {
|
||||
COPIES.push({
|
||||
from: pkgFile("tesseract.js-core", `${variant}.${ext}`),
|
||||
to: path.join(publicDir, "tesseract", `${variant}.${ext}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let copied = 0;
|
||||
for (const { from, to } of COPIES) {
|
||||
await mkdir(path.dirname(to), { recursive: true });
|
||||
await copyFile(from, to);
|
||||
const { size } = await stat(to);
|
||||
copied += size;
|
||||
console.log(
|
||||
` ${path.relative(root, to)} ${(size / 1024 / 1024).toFixed(1)}MB`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`browser assets staged in public/ (${(copied / 1024 / 1024).toFixed(1)}MB total)`
|
||||
);
|
||||
Reference in new issue
Block a user