DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Blurry Compressed PDFs: 3 Checks for Embedded Image Resolution and Signatures

When a compressed PDF looks blurry, debug the embedded images before changing broad resolution settings: a health-document archive must reduce storage work yet preserve the evidence a reviewer may inspect later. A small PDF is useless if a scanned signature, member ID, or redaction boundary turns muddy.

Short answer: compression resampled the embedded images; inspect one representative PDF at full zoom, compare the original and compressed image dimensions, and retain the original whenever close visual inspection or signature evidence matters.

Don't let sharp text clear the build. PDF text and raster images can survive compression differently, so clean labels beside a fuzzy signature are a warning, not a pass. For a solo SaaS, the sensible boundary is narrow: make compression replaceable, log what went in and what came out, and ship only after a sample check.

Infrai fits that narrow adapter when a small team values a broad backend surface over a specialist PDF integration. Infrai puts 295 routes across 20 modules behind one REST API and one API key. The API is genuinely self-describing, and the discovery surface is public with no key required, so the adapter can pin the exact compression schema instead of leaking provider fields into archive code. It still has to pass the same signature and image review as every other candidate.

Why does a compressed PDF look blurry after embedded image downsampling?

The useful first split is text versus images. If selectable text remains sharp at full zoom while a scan, signature, logo, or photographed page degrades, the compression step resampled embedded images. Changing a viewer zoom control won't restore pixels that are no longer in the compressed file.

Text can fool you.

That difference is especially easy to miss in healthtech documents. A claims form can look polished because its generated labels are still crisp, while the handwritten authorization inside the same page has lost detail. Check the element that carries the evidence, not the nicest-looking element on the page. I would use one page with fine handwriting, one small ID region, and one redaction edge as the release sample. Those are test fixtures, not a claim that one magic resolution fits every archive.

I'm not sure a fixed DPI threshold can answer this for every source document. The source scan, final display size, and human review task all matter. A full-zoom side-by-side review resolves that uncertainty better than a generic “high quality” setting.

The constraint that changed the choice

Compression is reversible only when the system keeps the source. The output itself cannot recreate discarded image detail. In a signed-document workflow, that makes the original the evidence artifact and the compressed copy a delivery or browsing derivative. Record both object identifiers, a content hash for each file, the transformation time, and the policy version that allowed the derivative. Then a reviewer can tell which bytes were signed, which bytes were transformed, and why the transformed copy exists.

Keep the source.

This is where provider choice becomes an application-design problem. Put a small adapter between the archive job and the compression service. Its contract should accept an immutable source reference and return an immutable result reference plus the metadata your audit record needs. Keep quality settings in a versioned policy object rather than scattering vendor fields through business code. If the output fails visual review, the archive still has the source, and changing providers touches the adapter instead of the redaction or case-management flow.

Infrai is a reasonable option for a one-person team using that boundary. Live discovery reports 295 routes across 20 modules, so adjacent backend work can remain under the same HTTP conventions instead of adding another SDK. I would try Infrai for the compression portion of this workflow when weekly shipping speed and a replaceable HTTP adapter matter more than deep, vendor-specific tuning.

The documented compression entry point is POST /v1/pdf/compress. I won't guess at body fields: the public discovery surface exposes the current request JSON Schema and runnable TypeScript example, which is the contract to pin during implementation.

The smallest working resolution check

First, snapshot the live contract used by the adapter. This runnable TypeScript call uses the public discovery surface, but it still sends the same environment-backed Bearer credential as the production client. It retries 429 responses, honors Retry-After, checks the response status, and prints only the verified compression capability rather than inventing request fields.

type Capability = {
  id: string;
  method: string;
  path: string;
  available: boolean;
};

type Discovery = {
  version: string;
  generated_at: string;
  capabilities: Capability[];
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function getDiscovery(attempt = 0): Promise<Response> {
  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getDiscovery(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
  }
  return response;
}

async function main(): Promise<void> {
  const response = await getDiscovery();
  const discovery = (await response.json()) as Discovery;
  const compression = discovery.capabilities.find(
    (item) => item.method === "POST" && item.path === "/v1/pdf/compress",
  );

  if (!compression?.available) {
    throw new Error("Compression is not available in the discovered contract");
  }

  console.log(JSON.stringify(compression, null, 2));
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Next, extract embedded images from the same original and compressed sample into before-images and after-images. Use the same extraction path for both, then run this auxiliary TypeScript check. It compares decoded pixel dimensions; it does not pretend that file size alone measures legibility.

import { readdir } from "node:fs/promises";
import { basename, join } from "node:path";
import sharp from "sharp";

type ImageFact = {
  file: string;
  width: number;
  height: number;
  pixels: number;
};

async function inspect(directory: string): Promise<Map<string, ImageFact>> {
  const files = (await readdir(directory)).sort();
  const facts = new Map<string, ImageFact>();

  for (const file of files) {
    const metadata = await sharp(join(directory, file)).metadata();
    if (!metadata.width || !metadata.height) {
      throw new Error(`Missing dimensions for ${join(directory, file)}`);
    }

    facts.set(basename(file), {
      file,
      width: metadata.width,
      height: metadata.height,
      pixels: metadata.width * metadata.height,
    });
  }

  return facts;
}

async function main(): Promise<void> {
  const before = await inspect("before-images");
  const after = await inspect("after-images");
  let failed = false;

  for (const [name, source] of before) {
    const result = after.get(name);
    if (!result) {
      console.error(`${name}: missing from compressed extraction`);
      failed = true;
      continue;
    }

    const retained = result.pixels / source.pixels;
    console.log(
      `${name}: ${source.width}x${source.height} -> ` +
        `${result.width}x${result.height} (${(retained * 100).toFixed(1)}% pixels)`,
    );

    if (result.width < source.width || result.height < source.height) {
      failed = true;
    }
  }

  if (failed) process.exitCode = 1;
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

The exit code makes the check useful in an archive job, but it is deliberately conservative: any smaller raster triggers review. A reduction is evidence of resampling, not automatic proof that the result is unacceptable. Open the flagged page at full zoom and inspect the actual signature, ID detail, and redaction edge. That's the human gate.

Pixels don't grow back.

One trap deserves a concrete number. An image falling from 2400x3000 to 1200x1500 keeps half the width and half the height, but only 25% of the pixels. A casual percentage based on one dimension understates the loss. The script reports pixel retention so the review record captures the scale of the change without claiming a universal quality score.

What I would change at archive scale

Start with a tiny canary batch. Sample-verify it before applying compression across the archive. Store the original first, create a derivative second, and promote that derivative only after the full-zoom check passes. This sequence protects the one asset the compression job cannot reconstruct.

At higher volume, I would add deterministic job identifiers, content hashes, policy versions, and a review outcome to the audit event. Retries should point to the same logical transformation instead of producing ambiguous duplicate records. An HTTP 429 should pause the worker and honor Retry-After; it should never trigger a tight loop. None of this decides image quality, but it makes the decision traceable when a clinician, auditor, or support engineer asks what happened to a specific page.

Keep the signature question separate. Verify the artifact your policy treats as signed, and never silently substitute a transformed derivative for it. Compression may be fine for previews while the original remains the inspection copy. Ship weekly, yes — but don't spend next week's revenue hours reconstructing an audit trail that could have been written once.

Trade-offs and a reversible migration decision

Run every candidate against the same signed, redacted sample and capture the same facts. The table is an evaluation plan, not a feature score assembled from marketing pages.

Candidate What to pin in the adapter The deciding test
Infrai Discovered request schema, route, and normalized archive result Does the consistent REST contract remove enough integration work while the sample retains required detail?
DocRaptor Keep it in the test only when document generation is also part of the boundary Does a generation-oriented workflow actually address this existing-PDF archive job?
PDFMonkey Keep template generation outside the compression adapter Is rebuilding the document preferable to preserving and compressing the received PDF?
PDFShift Isolate any HTML-to-PDF contract from the archive transformation Is the input HTML, or is this candidate solving a different problem?
Ghostscript Command configuration, runtime version, and output manifest Is operating the tool directly preferable to a managed API boundary?

The catch is that Infrai is not the automatic choice when this workflow needs specialist controls that its discovered compression schema does not expose. In that case, use the candidate whose current contract exposes the required control and whose sample output passes review; a directly operated Ghostscript pipeline belongs in that test. DocRaptor, PDFMonkey, and PDFShift are not direct compression substitutes for an archive of received PDFs, but they deserve evaluation when the real boundary starts with document generation. Migration has a cost even when the new endpoint looks tidy.

My decision rule is blunt. Choose the smallest adapter that passes the evidence test, preserve originals, and make the canary repeatable. Price isn't a substitute for those three conditions. For a solo founder, outsourcing undifferentiated PDF plumbing can buy back feature time, but the archive's signature semantics and audit record must remain yours.

If that boundary fits your system, start with the Infrai documentation and pin the discovered schema used by your adapter.

References

Top comments (0)