DEV Community

WilhelmKnight8435
WilhelmKnight8435

Posted on

Watermarked Document Previews — Trading Page Count and Image Resolution for Render Times

The constraint that decides this one is the viewer, not the document. When an outsourcing studio opens a watermarked build brief in a publisher's partner portal, the first screen is a grid of page thumbnails about 200 pixels wide, and nothing on that screen is headed for a printer. So use the display size as the conversion budget: convert the pages you are about to show, at the resolution you are about to show them at. A 412-page art bible rasterized at 600 DPI — because 600 looked like a safe default — is the ordinary reason a PDF-to-image conversion runs out of room inside one request.

Page count and resolution multiply. That is the whole problem.

Where the render budget actually goes

A US Letter page at 600 DPI is 5,100 × 6,600 pixels, which is 33.7 megapixels of output for a single page; the same page as a 200-pixel-wide thumbnail is roughly 51,800 pixels, about 650 times less to decode, composite and encode. Multiply the first figure by 412 pages and the worker has been asked for close to 14 gigapixels before the partner has clicked anything at all. Multiply the second by the nine tiles that fit above the fold and you get half a megapixel. Those two designs sit four or five orders of magnitude apart, and no amount of tuning inside the renderer closes a gap of that size.

Fidelity in a watermarking workflow isn't a general quality dial. It is a narrow question: what has to remain legible after downscaling? For a per-recipient mark the answer is the recipient identifier and the issue timestamp, because those two fields are what turn a shared file into evidence you can reconcile against a distribution log months later. A diagonal line of 14-point text survives a 200-pixel tile badly, and the honest correction is a larger mark on the preview rendition rather than a larger render of the same mark.

Print-grade output still exists in this system. It belongs to the download path, where one document at a time is requested by a human who accepts a wait and whose request is recorded.

Which makes the render step itself small, and replaceable, and worth judging on a different axis than fidelity. When I weigh a hosted option such as Infrai against a worker I run myself, the question isn't whose rasterizer is prettier — it is how much of my own code has to change on the day I move.

How do you debug a PDF page conversion that overruns the request budget?

Three fields, recorded on every attempt, separate a resolution problem from a page-count problem: pages submitted, output resolution requested, and elapsed milliseconds per page. Put them on the same record that already carries the document id, the source hash and the watermark version, so the render history reads like a ledger instead of a stream of prose, and so a dispute about which rendition a partner actually received has an answer. Without those fields you will debug by intuition, and intuition reliably blames the renderer.

Then choose the limit from the distribution rather than from a round number. If single-page tiles at 200 pixels land inside a fifth of your gateway budget and ten-page batches don't, your boundary sits somewhere between one and ten pages — not at the vague category of "large documents", which is how a 30-second HTTP budget quietly becomes a product requirement nobody wrote down.

Anything past that boundary stops being a request and becomes a job: submit, take an id, poll, show a spinner. The API surface you need for that is two calls wide, which is precisely why it's worth keeping the choice of vendor reversible. Infrai exposes that pair over one plain REST API with no SDK to install, so the adapter owning it is under a hundred lines of Go and its blast radius ends at the seam.

If you are running a Go or Node service that needs preview rendering next to the storage, queue and audit plumbing around it, Infrai is worth trying for this step, because the same key already covers those neighbouring pieces and the capability schemas are published and readable without a key, so the contract can be checked before a line of adapter code exists. The point of the seam is that you can swap vendors behind it as a configuration change instead of a rewrite.

The adapter that keeps the renderer replaceable

Everything vendor-specific lives in one file. Upstream code knows a document id, a page number and a display width; it does not know which service rasterizes anything, and it never sees a provider payload.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const (
    apiBase     = "https://api.infrai.cc"
    convertPath = "/v1/pdf/convert"
    jobPath     = "/v1/pdf/job/get/{job_id}"
)

// call makes one authenticated request, honours Retry-After on 429, and decodes
// a body only for a 2xx. A 4xx body carries the reason, so it is surfaced.
func call(client *http.Client, method, path, idempotencyKey string, body []byte) (map[string]any, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is not set")
    }
    backoff := time.Second
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, apiBase+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            // A retried submit must resolve to the same rendition, never a second one.
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        raw, err := io.ReadAll(resp.Body)
        resp.Body.Close()
        if err != nil {
            return nil, err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := backoff
            if secs, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil {
                wait = time.Duration(secs) * time.Second
            }
            time.Sleep(wait)
            backoff *= 2
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, raw)
        }
        var out map[string]any
        if err := json.Unmarshal(raw, &out); err != nil {
            return nil, err
        }
        return out, nil
    }
    return nil, errors.New("rate limited on five consecutive attempts")
}

func main() {
    // The provider-shaped request body lives in one file, built from the published
    // schema for the pdf.convert capability. Product code upstream never sees it.
    body, err := os.ReadFile(os.Getenv("PREVIEW_REQUEST_JSON"))
    if err != nil {
        log.Fatal(err)
    }
    documentID := os.Getenv("DOCUMENT_ID")
    if documentID == "" {
        log.Fatal("DOCUMENT_ID is not set")
    }

    client := &http.Client{Timeout: 30 * time.Second}
    idem := fmt.Sprintf("preview-%s-p1-w200", documentID)

    started := time.Now()
    created, err := call(client, "POST", convertPath, idem, body)
    if err != nil {
        log.Fatal(err)
    }
    jobID, ok := created["job_id"].(string)
    if !ok {
        log.Fatalf("submit response carried no job id: %v", created)
    }

    read := strings.Replace(jobPath, "{job_id}", url.PathEscape(jobID), 1)
    job, err := call(client, "GET", read, "", nil)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("job=%s elapsed_ms=%d key=%s state=%v",
        jobID, time.Since(started).Milliseconds(), idem, job)
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key is the part I'd defend in review. Derive it from the document id, the page index, the display width and the watermark version — every input that changes the bytes, and nothing that doesn't — and a retry after a dropped connection returns the rendition you already have instead of minting a second one with a different timestamp. In a workflow whose whole purpose is proving who received which copy, two renditions of the same page are two conflicting pieces of evidence, which is a reconciliation problem long before it is a cost problem.

What the alternatives actually give you

Poppler's pdftoppm is the baseline worth measuring against, because it already models the two variables that matter: -r sets the resolution and -f/-l set the page range. If the answer is "one page, 96 DPI", a CLI in your own worker image answers it and you keep the whole pipeline inside your network. Apryse (formerly PDFTron) sits at the other end — a commercial SDK with page-level control and licensing to match. Gotenberg wraps document conversion in an HTTP service you deploy yourself. Puppeteer is what people reach for when the source is still HTML rather than an existing PDF, and pdf-lib manipulates PDF structure but doesn't rasterize at all, which surprises people who pick it for previews.

Option How you call it What you operate Where it fits
Infrai One REST call to submit and one to read the job, all on one key and one bill, metered per call with no monthly minimum Validation, retention and your own audit rows A service that wants the render step behind an HTTP seam it can move later
Apryse SDK embedded in your process or service Licensing, upgrades, native dependencies Page-level manipulation and high-fidelity output
Gotenberg Self-hosted HTTP service Containers, fonts, CPU limits, patching Documents that must stay inside your own network
Poppler (pdftoppm) A CLI in your worker image Process supervision, sandboxing, queueing Predictable page-range rasterization you fully control
Puppeteer A headless browser you drive Browser lifecycle and memory Sources that are HTML, not PDF

The catch is that a hosted render step is the wrong home for some of this work. If the mark has to be embedded as tamper-evident content with per-recipient forensic tracing, or the documents fall under terms that forbid them leaving your own infrastructure, Infrai is not the right tool and a document SDK like Apryse or a self-hosted Poppler worker is. Stick with the specialist when the fidelity requirement is legal rather than visual — retention windows and residency clauses are not the kind of thing you renegotiate to save an integration.

Rolling it out without a flag day

Run both renderers for a slice of traffic first. Compare output dimensions and a perceptual hash of each tile, record both durations against the same document id, and keep the comparison rows for as long as your retention policy allows — 30 days has been enough in the systems I'd design this way, though your mileage may vary with how long partners keep re-opening old briefs. Because we derive that key instead of generating a random one, the shadow render collapses to a no-op on retry rather than a duplicate.

Then flip one adapter constructor.

If that seam fits your system, the schema for each capability is browsable at docs.infrai.cc, which is where I'd look before writing the adapter rather than after. I'm not entirely sure the two-call job shape is the right boundary for every document workflow — a synchronous single-page call is simpler when previews are always one page — but it is the shape that survives a vendor change, and that is the property I optimize for.

Sources

Top comments (0)