Photographing a page and turning it into a clean, printable PDF is, on paper, a solved problem: read a file, fix the perspective on a canvas, flatten the shading, write a PDF. Browsers have shipped every primitive for that for years, and none of it needs a server.
I work on LensUp, which does exactly that — the whole pipeline runs in the tab, and files are never uploaded. Disclosure up front: it's our tool, and this post is about the parts that were harder than the pipeline itself. None of the three below are in the "how to draw an image to a canvas" tutorials, and all three cost us real debugging time. Web Share can be gone by the time your file is ready
The Web Share API's file variant is the nicest way to hand a generated PDF to whatever the user actually wants to do with it. The naive version looks fine:
navigator.share() requires transient user activation, and transient activation expires. Building a multi-page PDF from full-resolution photos on a mid-range phone takes long enough that by the time you call share(), the activation from the tap is gone. You get a NotAllowedError, the share sheet never opens, and it only reproduces on slow devices — which is the worst possible failure profile.
There is no way to extend the activation. What you can do is decouple "prepare" from "share", and check whether you still have activation before deciding which one you're doing:
navigator.userActivation.isActive is the part worth knowing about. It lets you tell the difference between "this will work" and "this will throw", so you can degrade to an honest two-tap flow instead of showing an error for something the user did nothing wrong in. On a fast device the second tap never happens; on a slow one the user gets a clear "ready, tap again" instead of a failure.
Feature-detect the file variant, not the API. 'share' in navigator tells you nothing about whether files can be shared — that support is separate, and it varies. The only honest probe is to build a real File of the type you intend to send and ask:
Those four bytes are %PDF. Building the probe file from the real MIME type matters, because canShare can accept one type and refuse another.
AbortError is not an error. When the user opens the share sheet and dismisses it, share() rejects with AbortError. If you surface that as a toast, you are telling people something failed when they simply changed their mind: One worker per image beat a worker pool
Finding the page corners in a photo — the geometry that turns a trapezoid back into a rectangle — is the one genuinely CPU-heavy step, and it has no business on the main thread while someone is trying to scroll.
The obvious architecture is a long-lived worker (or a small pool) plus request IDs, so you can match a response to the request that asked for it. We ended up with the opposite: spawn a worker for one image, then terminate it.
Stale results become structurally impossible. With a shared worker, a response from the image the user already replaced can arrive after you've moved on, and you are one forgotten ID comparison away from cropping photo B by photo A's corners. Terminating the worker deletes that bug class instead of guarding against it.
Memory releases deterministically. A decoded ImageBitmap from a 12-megapixel photo is tens of megabytes. terminate() takes the whole worker heap with it, which is a much shorter argument than reasoning about when the bitmap becomes unreachable inside a worker that keeps running. The worker side still closes it explicitly, because the tab may be doing several things at once:
Cancellation is just terminate(). No cooperative abort checks inside the detection loop, no message protocol for "never mind".
The cost is real — you pay worker startup per image, and on a cold module worker that is not free. For a user importing a handful of pages, that cost is invisible; if you were detecting corners on a video stream at 30fps, you would want the pool and the request IDs.
Note what the failure path does: every error resolves to null, and null means keep the full original image. A scanner that crops wrong is worse than a scanner that doesn't crop, so the degraded state is "you get your whole photo" rather than "you get two thirds of your passport". "Compress to 200 KB" is a search problem, not a setting
Government portals and university systems love a hard byte ceiling: PDF, under 200 KB, colour, A4. Developers see that requirement and go looking for the quality parameter that produces 200 KB.
There isn't one. The size of an encoded JPEG is a function of the image content as much as the quality setting — a dense page of small text and a mostly-white form at the same quality can differ several-fold. The only thing you can do is encode, measure, and step:
