LintPDF LintPDF

Viewer Capabilities & On-Demand Fill-In

How the viewer gates preflight tools based on per-job capabilities and how to fill gaps on demand.

Viewer Capabilities & On-Demand Fill-In

Every LintPDF job carries a data_capabilities map — a set of boolean flags that tells the viewer which preflight tools have authoritative data to work with. Capabilities are populated differently in each submission mode:

Modefindingsseparationstactac_runsfontsimageslayers
Engine (default)✓ (tracks tac)✓ when present
Externaldepends on reportusually ✗usually ✗usually ✗depends on reportdepends on report✓ when present
Minimal✓ when present

The viewer reads capabilities from GET /api/v1/viewer/jobs/{job_id}/config and renders each preflight tool accordingly:

  • true → tool is fully active.
  • false and fill-in supported → tool renders a Load button that triggers on-demand fill-in.
  • false and fill-in unsupported (layers only) → tool is hidden.

Viewer tier gate

The same config response surfaces three plan-level gates alongside the per-job capability map:

FieldTypeWhen false / empty
capability_fillin_enabledboolFill-in Load buttons are hidden; POST .../capabilities/{capability} returns 403 plan_upgrade_required.
annotations_enabledboolThe annotation toolbar is hidden; annotation write endpoints return 403; share-link tokens minted by this tenant force allow_annotations=false regardless of request.
allowed_report_formatsstring[]Viewer download chrome is hidden; POST /api/v1/jobs/{id}/reports with any downloadable format returns 403 plan_upgrade_required. Share-link minting (no formats) still succeeds.

Viewer-tier tenants see all three as false / []. Starter and above see them as true / ["json","html","pdf","xml",...]. Frontends should render the shared UpgradePrompt component instead of Load buttons whenever the matching gate is off.

The capability registry

CapabilityFillableBacking analyzerViewer tool
findingsFull engine pipeline (all 500+ checks)Findings panel
separationsSpot-color analyzerSeparations viewer + ink channel rasters
tacInk-coverage analyzerTAC heatmap overlay
tac_runs✗ (derived on demand)Same CMYK raster as tac plus pdftotext -bboxPer-text-run tooltip on the TAC overlay (hover to read each run’s mean TAC%)
tiles_warmed✗ (set by background task)lintpdf.viewer.warm_tiles Celery taskFlips to true once every page tile is cached in S3. Drives the viewer’s browser-side prefetch pass. Tracked in Redis at lintpdf:tile-warm:{job_id}; poll /api/v1/viewer/jobs/{job_id}/tile-warming for progress.
fontsFont analyzerFont inspector
imagesImage analyzerImage inventory
text_regionsShared OCR text-region pass (PaddleOCR, multilingual ml) — runs on pages with placed-image area > 25 % or path-heavy / text-light. Per-page output in PDF points: [{bbox, text, confidence, polygon, source}].Highlights outlined captions and fold-zone text in the viewer; consumed by safe_zone_violations, text_as_outlines, and color/legibility analyzers.
layersExtracted from PDF at job creation; not re-derivableLayers panel (interactive via ocg_on / ocg_off query params on the tile endpoint — see below)
thumbnails✓ (always populated on complete)Page rasterizerPage thumbnails strip
metadata✓ (always populated on complete)PDF metadata extractorDocument info panel
art_info✗ (filled at ingest, not re-derivable)Dieline detector (name-match + Sonnet fallback) + art-size inspector (dieline centerline) + legend-vs-art classifier + Claude OCR for outlined textArt Info panel — trim size, dieline overlay toggle, OCR text-layer toggle, swatch list with legend/art badges. Gated on the tenant’s ai_features grants (dieline, art_size, legend, ocr); locked features surface as LPDF_FEATURE_LOCKED findings instead of fields.
ai_explain✗ (on-call, not via fill-in)lintpdf.ai.explain (Claude Haiku 4.5)Per-finding “Explain” button. Cached on the finding row; populated by POST /api/v1/jobs/{job_id}/findings/{finding_id}/explain. Cost-cap gating returns 402 when the tenant exceeds their monthly cap. The POST .../capabilities/ai_explain fill-in path is not supported — use the explain endpoint per-finding.
epm_verdict✗ (computed at ingest, re-run only via re-submit)lintpdf.epm.scoring.score_epm_candidacy over the job’s fired LPDF_EPM_* findingsEPM candidacy header (tier badge, rejection drivers, advisories, IndiChrome upsell hint). Mirrored inline on JobResponse.epm_verdict and via GET /api/v1/jobs/{job_id}/epm.

Unknown capability names are ignored; the map is forward-compatible.

Triggering fill-in

curl -X POST https://api.lintpdf.com/api/v1/viewer/jobs/{job_id}/capabilities/separations \
  -H "Authorization: Bearer lpdf_live_..."

Response:

{
  "job_id": "d4e5f6a7-...",
  "capability": "separations",
  "status": "queued",
  "task_id": "celery-a1b2c3..."
}

status values:

  • queued — a Celery task has been enqueued; poll /config to see the capability flip.
  • already_filled — the capability is already true; no-op.

The endpoint requires Authorization: Bearer <key> and the preflight:submit permission. There’s no separate permission for capability fill-in — if you can submit jobs, you can fill capabilities.

Polling pattern

import time, requests

def fill_and_wait(job_id, capability, timeout=60):
    headers = {"Authorization": f"Bearer {API_KEY}"}
    requests.post(
        f"https://api.lintpdf.com/api/v1/viewer/jobs/{job_id}/capabilities/{capability}",
        headers=headers,
    ).raise_for_status()

    deadline = time.time() + timeout
    while time.time() < deadline:
        config = requests.get(
            f"https://api.lintpdf.com/api/v1/viewer/jobs/{job_id}/config",
            headers=headers,
        ).json()
        if config["capabilities"].get(capability) is True:
            return config
        time.sleep(1)
    raise TimeoutError(f"Capability {capability} did not fill within {timeout}s")

fill_and_wait("d4e5f6a7-...", "separations")

Error responses

StatusReason
400capability is not in the registry.
404Job does not exist or is not owned by your tenant.
409Job is not in complete state — wait for completion before requesting fill-in.
422Capability is known but not fillable on this job (typically layers on a PDF with no OCGs).

Share links preserve the capability state captured at report-mint time. Capability fill-in performed after a share link was minted does not retroactively surface in that link — the link continues to show the set of capabilities that were filled when the token was created. This matches the broader “share links are immutable captures” rule; see Share Links.

To refresh a share link with newly-filled capabilities, revoke the old token and mint a new one.

Tile pre-warming

Every completed job fires a background Celery task (lintpdf.viewer.warm_tiles) that pre-renders each page’s tile into S3 at the default viewer DPI (150) plus the thumbnail DPI (72). This removes the ~500–2000 ms Ghostscript render from the viewing path — reviewers get warmed cache hits on every page click.

Progress lives in Redis at lintpdf:tile-warm:{job_id} as a hash with status, rendered, total, dpi, started_at, updated_at, completed_at fields. The viewer polls a dedicated endpoint every 1.5 s:

curl https://api.lintpdf.com/api/v1/viewer/jobs/{job_id}/tile-warming \
  -H "Authorization: Bearer lpdf_live_..."

Response:

{
  "job_id": "d4e5f6a7-...",
  "status": "in_progress",
  "rendered": 7,
  "total": 20,
  "dpi": 150,
  "percent": 35,
  "started_at": "2026-04-14T10:22:13.441Z",
  "completed_at": null,
  "error": null
}

status values:

  • pending — job isn’t complete yet, or completed before the warming feature shipped.
  • in_progress — worker is rendering; rendered increments per page.
  • complete — every page tile is in S3. The viewer kicks off a browser-side prefetch pass at this point.
  • failed — worker crashed; error carries a short message.
  • disabled — warming is off (no Redis configured, or the LINTPDF_TILE_WARMING_ENABLED env gate is false). The viewer silently falls back to on-demand render.

The same endpoint is mirrored for share-link viewers at /api/v1/viewer/public/{token}/tile-warming — no auth, token-gated. Once warming settles, the tiles_warmed capability on /config flips to true and the viewer starts prefetching tile bytes into the browser’s HTTP cache so page clicks paint in <20 ms.

Warming configuration

Env varDefaultDescription
LINTPDF_TILE_WARMING_ENABLEDtrueGlobal kill switch. Set to false to disable auto-enqueue on job completion — the /tile-warming endpoint then reports status="disabled" and the viewer falls back to on-demand rendering.
LINTPDF_TILE_WARMING_PER_TENANT_MAX3Maximum concurrent warming tasks per tenant. Enforced via a Redis semaphore at lintpdf:tile-warm-sem:{tenant_id}. Bulk-upload tenants that exceed the cap get their jobs re-queued with a ~20 s delay until a slot frees, so one tenant can’t starve the worker pool. 0 disables the cap.
LINTPDF_TILE_WARMING_INCLUDE_SEPARATIONStrueWhen on, warming also renders CMYK channel + spot-color rasters into S3 so the first click on the Separations panel / Densitometer doesn’t pay the ~2 s Ghostscript cost. Turn off for tenants whose workflow never opens the separations UI — halves total warm time on large PDFs.
LINTPDF_TILE_HOT_CACHE_ENABLEDfalseOpt-in Redis byte-cache for the default-DPI tile endpoint. When on, tiles served within 15 minutes skip even the S3 GET (~1–3 ms instead of ~100–200 ms). Off by default because PNG tiles are 50–500 KB and Redis memory is precious on smaller plans. Production tenants with a generously sized Redis can flip this on per-environment.

Admin warming dashboard

Every warm_viewer_tiles run persists a tile_warm.complete or tile_warm.failure event into two capped Redis lists so super-admins can inspect warming health without spelunking Railway logs:

  • lintpdf:tile-warm-events:{tenant_id} — per-tenant list, capped at 500, 7-day TTL.
  • lintpdf:tile-warm-events:_all — global list across every tenant, same cap + TTL.

Event payload (identical to the structured log emitted alongside):

{
  "event": "tile_warm.complete",
  "job_id": "…",
  "tenant_id": "…",
  "page_count": 20,
  "dpi": 150,
  "thumbnails": true,
  "duration_s": 12.4,
  "error": null,
  "recorded_at": "2026-04-14T10:22:25.441Z"
}

Three super-admin endpoints expose the data (all require the X-Admin-Key header):

EndpointDescription
GET /api/v1/admin/tile-warming/events?tenant_id=&limit=Last N events newest-first. Omit tenant_id for the global feed; limit clamps to 1..500 (default 100).
GET /api/v1/admin/tile-warming/summary?since_hours=Aggregates over the last since_hours (1..168, default 24): total completes/failures, p50/p95/p99 duration, per-tenant breakdown, top 5 error messages.
GET /api/v1/admin/tile-warming/jobs/{job_id}Current Redis status hash for the job plus the subset of _all events that match.

When Redis is not configured every endpoint returns {"events": [], "status": "no_redis"} (or the per-response equivalent) with HTTP 200 so callers can render an informational banner instead of an error. Structured tile_warm.* log lines continue to emit regardless — Redis persistence is additive.

The super-admin dashboard at /dashboard/admin/warming polls /summary and /events every 5 s and surfaces a summary strip, per-tenant health table, top error messages, and a filterable live event feed.

Counting and billing

Each capability fill-in counts as one analyzer invocation against your plan. Typical cost is one-fifth to one-tenth of a full engine run, depending on the capability:

  • findings fill-in ≈ one full engine run (it is a full engine run).
  • separations, tac, fonts, images — one analyzer each, billed at the single-analyzer rate.

Check your plan’s analyzer-invocation allowance at Pricing.

Interactive layer (OCG) isolation

The layers capability is not fillable — it’s discovered at ingest from the PDF’s /OCProperties/OCGs array — but the tile endpoint accepts an override mask so clients can render any combination of layers on demand:

GET /api/v1/viewer/jobs/{job_id}/pages/{page_num}/tile
    ?dpi=150
    &ocg_on=0,3        # force these layers visible
    &ocg_off=2         # force these layers hidden

Rules:

  • Indices match ocg_index returned by GET /api/v1/viewer/jobs/{job_id}/layers.
  • Either param may repeat (?ocg_on=0&ocg_on=3) or be a comma list.
  • An index present in both ocg_on and ocg_off returns 422.
  • An index outside the OCGs array returns 422.
  • Calling this on a PDF without /OCProperties returns 422 with a clear “no layers to toggle” message.
  • Response bytes for the no-params case are byte-identical to the default-state tile and share its S3 cache key, so warm-cache hits are preserved. Every distinct (ocg_on, ocg_off) pair gets its own cache entry keyed by sha256(sorted_on;sorted_off)[:12].

The rewriter modifies the PDF’s /Root/OCProperties/D/OFF list before handing bytes to poppler — no Ghostscript subprocess is introduced. Round-trip latency matches the standard tile render (~500ms–2s on-demand, ~100ms when a prior request populated the cache).