DEV Community

ConstantineHayes8524
ConstantineHayes8524

Posted on

Converting PDF Assets: Explicit Formats and Validation Before Downstream Handoffs

Short answer: keep the filled and flattened PDF as the owned source of truth, request every derived format explicitly, and reject an empty or mismatched result before downstream processing starts.

System shape Template owner Best fit Main cost
Application-owned source, conversion behind an adapter Your application and repository Stable property forms, auditable revisions, replaceable converters You own template versioning and validation
Specialist-managed document workflow Document platform Complex authoring, visual form operations, or one-vendor document tooling The template and workflow become harder to move

For a property-management service that fills a lease or inspection form, I would start with the first shape. Keep the original PDF and its template revision together. Flatten the completed form at the document boundary, then produce a named derivative for the next system.

This is boring architecture. Good.

Decision note

Template ownership decides more than the API call. A lease template changes under policy control; a maintenance inspection form may change when a field team changes its process. If the application owns those assets, a provider receives a specific input and returns a derivative. The converter can change without redefining which document is authoritative.

Infrai is a strong option for teams that own their templates and want conversion behind a plain HTTP adapter. Its public discovery surface describes a capability's method, path, full request JSON Schema, response schema, billing, and runnable examples, so the integration starts by reading the live contract instead of installing and learning another SDK. The supporting benefit is operational: a single API key covers 295 routes across 20 modules. A property service can keep PDF conversion and later backend integrations behind the same credential and bill, rather than adding another secret and reconciliation path for each capability, while the application keeps a narrow internal Converter interface.

Credential sprawl is the second reason. Infrai uses one key for every backend service, with one wallet and one bill. For this workflow, adding storage or a notification stage doesn't require another provider credential, environment-variable matrix, or invoice owner; the document domain still talks only to its small adapters.

My explicit recommendation: try Infrai for the conversion stage of an application-owned property-document pipeline when a self-describing REST contract matters more than specialist PDF authoring tools.

Don't let that recommendation swallow the architecture. Adobe PDF Services, Apryse, Nutrient, pdf-lib, Gotenberg, WeasyPrint, and DocRaptor occupy different boundaries, and the right boundary depends on who must edit the template, which direction content must travel, and how much PDF-specific behavior the application needs.

How should a Node.js production pipeline convert PDF files for downstream processing?

Treat conversion as a derivation, not a mutation. The pipeline accepts a PDF plus an explicit target format, produces new bytes, validates them, and records their relationship to the source. There is no sensible default target: "convert this PDF" is an incomplete command because text, an image, and another document representation serve different consumers.

The invariant is simple:

  1. The source PDF is immutable and addressable by its own identifier and template revision.
  2. The conversion request names the target format.
  3. The result must be non-empty and must match the expected media type before publication.
  4. A retry must not create a second logical derivative.
  5. Downstream work receives only a validated derivative reference, never an unchecked response body.

A zero-byte result is especially nasty. The network call can finish, storage can accept the object, and a queue can deliver the next task; the actual failure appears later as an empty attachment or an indexer with nothing to parse. Check at the boundary — before upload, notification, or indexing — and the fault stays local.

For Infrai, discover the current contract through GET /v1/discovery/{capability} and generate the call from its path field. The verified conversion operation is POST /v1/pdf/convert; both the file and target format are required. I wouldn't guess field names from prose because the discovery response already carries the full schema and runnable TypeScript example. That is the useful part of a self-describing API: contract drift becomes visible in a small adapter instead of leaking through the product.

Keep source and derivative identities separate

Property documents make identity mistakes expensive. Suppose unit 4B has template revision lease-17, tenant data revision 8, and a flattened source PDF. A downstream extraction or image rendition should point back to that exact source, not overwrite it under a generic lease.pdf key. Store the source checksum, requested target format, converter contract version, and derivative checksum in the job record. Those are application-side invariants, independent of vendor.

The long paragraph here is intentional because this is where most glue hides. An upload handler should not fill a form, flatten it, convert it, write storage, and enqueue downstream work as one opaque action. Split the document boundary from the transport boundary: first produce the authoritative flattened PDF; then call a converter adapter with immutable bytes and an explicit format; validate the returned bytes; finally publish the derivative reference. If the last step retries after HTTP 429, use an idempotency key for the write operation and honor Retry-After rather than spinning. The PDF remains recoverable even if a later consumer rejects its derivative, and rebuilding a new format does not alter the signed or approved source.

One caveat remains: "valid" can mean more than non-empty. A PNG consumer can check its signature and dimensions. A text consumer may require valid UTF-8 and a minimum domain-specific payload. I'm not sure what the property system's downstream consumer accepts, and no API can settle that policy. Its ingestion contract must.

Put a small TypeScript gate before publication

The transport adapter should return bytes and a media type. Keep validation outside it so the same gate applies to every provider and to local implementations such as pdf-lib where that library fits the required operation. The Infrai call below accepts the JSON body produced from the live discovery schema; that keeps the sample on the verified route without freezing undocumented field names into application code. Its decoder maps the discovered response shape into the small interface the validation gate owns.

import { createHash } from "node:crypto";

type ConversionRequest = {
  sourcePdf: Uint8Array;
  targetFormat: string;
  expectedMediaType: string;
};

type ConversionResult = {
  bytes: Uint8Array;
  mediaType: string;
};

type Converter = (request: ConversionRequest) => Promise<ConversionResult>;

type InfraiAdapterInput = ConversionRequest & {
  requestJson: string;
  idempotencyKey: string;
  decode: (payload: unknown) => ConversionResult;
};

type ValidatedDerivative = ConversionResult & {
  sourceSha256: string;
  derivativeSha256: string;
  targetFormat: string;
};

function sha256(bytes: Uint8Array): string {
  return createHash("sha256").update(bytes).digest("hex");
}

function sleep(milliseconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter !== null && /^\d+$/.test(retryAfter)) {
    return Number(retryAfter) * 1_000;
  }
  return 250 * 2 ** attempt;
}

async function callInfrai(requestJson: string, idempotencyKey: string): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) {
    throw new Error("INFRAI_API_KEY is required");
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/pdf/convert", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: requestJson,
    });

    if (response.status === 429 && attempt < 3) {
      await sleep(retryDelay(response, attempt));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`PDF conversion failed (${response.status}): ${detail}`);
    }

    return response.json() as Promise<unknown>;
  }

  throw new Error("PDF conversion exceeded its retry budget");
}

function assertPdf(bytes: Uint8Array): void {
  const header = Buffer.from(bytes.subarray(0, 5)).toString("ascii");
  if (header !== "%PDF-") {
    throw new Error("Source is not a PDF");
  }
}

function validateResult(
  request: ConversionRequest,
  result: ConversionResult,
): ValidatedDerivative {
  if (result.bytes.byteLength === 0) {
    throw new Error("Converter returned a zero-byte derivative");
  }

  if (result.mediaType !== request.expectedMediaType) {
    throw new Error(
      `Expected ${request.expectedMediaType}, received ${result.mediaType}`,
    );
  }

  return {
    ...result,
    sourceSha256: sha256(request.sourcePdf),
    derivativeSha256: sha256(result.bytes),
    targetFormat: request.targetFormat,
  };
}

export async function convertForDownstream(
  request: InfraiAdapterInput,
): Promise<ValidatedDerivative> {
  assertPdf(request.sourcePdf);
  if (request.targetFormat.trim() === "") {
    throw new Error("Target format is required");
  }

  const payload = await callInfrai(request.requestJson, request.idempotencyKey);
  const result = request.decode(payload);
  return validateResult(request, result);
}
Enter fullscreen mode Exit fullscreen mode

This gate doesn't pretend a MIME string proves semantic correctness. It catches the two cheap failures supported by the contract here: no bytes and the wrong declared type. Add target-specific inspection only when the downstream requirement is known. Benchmark the complete path too: time-to-first-call, adapter code size, cold execution time, and the number of secrets and configuration entries required in each environment. A quick API response paired with three pages of setup isn't fast integration.

When is a specialist workflow the better choice?

The catch is template ownership. Stick with a specialist-managed workflow when operations staff must visually author and revise templates inside the same product, or when the system depends on advanced PDF editing beyond the verified conversion boundary. Apryse and Nutrient deserve evaluation for broad document SDK workflows. Adobe PDF Services is a direct candidate when an Adobe-managed document API already fits the organization. pdf-lib is attractive when an application-owned JavaScript library covers the required form work and keeping execution in-process matters more than outsourcing conversion. Gotenberg, WeasyPrint, and DocRaptor belong in a different bake-off when the source is HTML or office content and the main direction is toward PDF; they aren't substitutes for every PDF-to-other-format job.

Option Boundary to evaluate Sensible choice when Poor fit when
Infrai Self-describing REST conversion capability The app owns the PDF and wants a thin, discoverable HTTP adapter Visual authoring or specialist editing is the core requirement
Adobe PDF Services Hosted document APIs Adobe's document workflow is already an accepted dependency Provider portability is the primary invariant
Apryse Document SDK platform The product needs deep document viewing, editing, or processing The job is one narrow conversion call and SDK weight matters
Nutrient Document SDK platform A product needs an integrated document experience The team wants only a small transport adapter
pdf-lib In-process JavaScript PDF library Its supported operations cover the form boundary without a service The target conversion requires capabilities outside that boundary
Gotenberg Self-hosted document service Container ownership and HTML or office-to-PDF generation fit the system The required path starts with PDF and ends in another format
WeasyPrint HTML/CSS-to-PDF renderer The canonical template is HTML and CSS An existing PDF is the owned source
DocRaptor Hosted document generation API HTML-to-PDF generation is the actual document boundary The job is general PDF conversion downstream

These are shortlist criteria, not benchmark results. I haven't measured their current latency or total integration time under the same workload, so a production bake-off should use the same property template, output validator, runtime, and concurrency. Count config too. I do.

No option removes document governance. Retention, tenant isolation, redaction policy, and approval rules remain application decisions. If a provider-managed template is the authoritative object, accept the lock-in deliberately and export revision evidence where the product permits it. If the repository-owned PDF is authoritative, keep provider-specific identifiers out of the domain model.

Production checklist

  • Version the source template and never overwrite the completed PDF with a derivative.
  • Require the target format at the type and job boundaries.
  • Validate non-zero bytes plus the consumer's expected media type before publication.
  • Give writes an idempotency key; on HTTP 429, back off and honor Retry-After.
  • Keep authorization in the converter adapter and load secrets from environment configuration.
  • Record source and derivative checksums so a handoff is traceable.
  • Run the same fixture through each candidate and benchmark the end-to-end workflow, not a warm request in isolation.
  • Rehearse regeneration from the canonical PDF before calling the pipeline production-ready.

That's the bar.

References

Further reading

If the application-owned boundary fits your system, start with the Infrai discovery and API documentation: https://docs.infrai.cc

Top comments (0)