@disphere/pdf-reducer
v0.3.1
Published
Reduce the file size of phone-scanned PDFs by re-compressing embedded images, preserving all other content.
Readme
pdf-reducer
Reduce the file size of PDFs and images by reducing the size and/or quality of the images. When reducing the size of images within a PDF, everything else is preserved. When reducing images directly, JPG and PNG are supported.
Images are only ever reduced, never scaled up. If the size after the call is equal to or bigger than the original, the original is returned.
Never throws an error or broken result, always returns a smaller result or the original.
An onError hook can be used to diagnose problems without interrupting the call chain.
Runs in both Node.js and the browser. In Node.js it works out of the box; in the browser it needs a little bundler setup, described below.
Installation
npm install @disphere/pdf-reducerEmbedding in your application
This is the primary integration path. The public boundary is base64 string in → base64 string out.
import { reducePdf } from "@disphere/pdf-reducer";
const smallerBase64Pdf = await reducePdf(originalBase64Pdf);import { reduceImage } from "@disphere/pdf-reducer";
const smallerBase64Image = await reduceImage(originalBase64Image);Using it in the browser
The browser build carries no codec of its own.
It uses the @jsquash codecs, which locate their .wasm payloads at runtime with new URL("…_bg.wasm", import.meta.url).
Your bundler has to serve those assets and leave that lookup intact.
The second half is where it usually goes wrong. Bundlers pre-bundle dependencies by default and rewrite module URLs while doing so; the lookup then 404s, the codec never initialises, and because this library never throws you get your file back unchanged. The result is a page that looks like it works and silently does nothing.
For Vite, keeping the codecs out of dependency optimization is the whole fix:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
optimizeDeps: {
exclude: ["@jsquash/jpeg", "@jsquash/png", "@jsquash/oxipng", "@jsquash/resize"],
},
});Two things are not needed, despite what the usual wasm-threading advice suggests:
- No COOP/COEP headers / cross-origin isolation.
@jsquash/oxipngonly takes its multi-threaded path inside a Worker, so a main-thread call always loads the single-threaded build. - No wasm plugin. Once the packages are excluded from pre-bundling, Vite handles
new URL(…, import.meta.url)natively.
A complete, runnable setup — file picker, download, and an end-to-end suite that drives a real browser — lives in example/web.
Diagnosing a silent pass-through
Because neither reducer ever throws, "returned the original" is the answer to several very different questions: the image was already optimal, the PDF was signed, the base64 was malformed — or your bundler isn't serving the @jsquash .wasm assets.
onError can be used for diagnostic purposes:
const smaller = await reduceImage(base64Image, {
maxDimension: 192,
onError: (err) => console.error("[pdf-reducer]", err),
});It fires on a codec failure, an unrecognized format, malformed base64, a non-string input, and a missing encoder — and on each image the PDF reducer had to skip. It does not fire when the image simply could not be made smaller, since that is normal operation rather than a failure. An error thrown by the callback is ignored, so a broken listener cannot turn into an exception.
Custom codec
Both reducePdf() and reduceImage() accept an encoder option implementing the ImageEncoder interface, overriding the built-in default.
EncodeRequest is a union discriminated on format, so the tuning fields you get are the ones that actually apply — quality/wantGrayscale for JPEG, level for PNG:
import { reducePdf, type ImageEncoder, type EncodeRequest } from "@disphere/pdf-reducer";
const myEncoder: ImageEncoder = {
async encode(req: EncodeRequest): Promise<EncodedImage> {
return (...);
},
};
const smallerBase64Pdf = await reducePdf(originalBase64Pdf, { encoder: myEncoder });The encoder must not change the container.
Commands
The library exposes several commands for use in a shell.
Read-only inspection
The analyze-pdf command can be used to inspect a given PDF in terms of the amount of eligible images contained within and what might prevent a reduction in size.
It attributes every byte to a role (content stream, image, embedded font, metadata, …), and reports the dominant contributor.
Its "Embedded raster images" section lists every image XObject with the fields the re-compression gate depends on, and why each was or wasn't eligible.
npx -p @disphere/pdf-reducer analyze-pdf <input.pdf> --jsonReduce a PDF on disk
reduce-pdf reduces a PDF on disk.
It writes its output to a copy, so the original is always preserved.
npx -p @disphere/pdf-reducer reduce-pdf <input.pdf> [output.pdf]output.pdfdefaults to<input>.reduced.pdfnext to the input.- Prints the input size, output size, and the percentage saved. If nothing could be improved, it writes an identical copy and says so.
Reduce a JPEG or PNG image on disk
reduce-image reduces an image on disk.
It writes its output to a copy, so the original is always preserved.
npx -p @disphere/pdf-reducer reduce-image <input.jpg|png> [output]- The output path defaults to
<input>.reduced<ext>next to the input, reusing the input's extension. - The format is identified by magic bytes, not the file extension. Anything that is neither JPEG nor PNG is refused with exit 2 rather than silently copied.
Development
The project uses trunk-based development:
mainis always releasable and is the source for published releases.- Work happens on short-lived
feature/*branches, merged intomainvia pull request. Every push and PR runs build + test in CI (no publishing). - Releases are cut deliberately (a maintainer runs the release workflow with the target version); publishing is never automatic on a push to
main.
Commands
npm run build— compilesrc/→dist/(JS +.d.ts+ source maps).npm run typecheck— strict type-check ofsrc/+test/(no emit).npm run typecheck:src— strict type-check ofsrc/(no emit).npm run typecheck:test— strict type-check oftest/(no emit).npm run test— run the test suite (node --test, native TypeScript type-stripping — no build needed).npm run pack:local—build and pack intopack/disphere-pdf-reducer.tgz, for installing into another project without publishing.npm run format— formats the code using Prettier.npm run format:check— checks the code format using Prettier.npm run licenses— production dependency license summary.npm run licenses:check— fails on AGPL/GPL* in the production tree
Examples
example/web is an isolated project that installs the packed tarball and runs the library in a real browser.
It is the only place the browser code path is exercised, and it is not part of npm test or CI — see its README for how to run it.
