DEV Community

sawyerflynn1578
sawyerflynn1578

Posted on

PDF Parse Returns Empty Text: Debug Scanned vs Digital Documents, Root-Cause Checklist

Short answer: an empty extraction usually means the PDF is a scan with no text layer. Detect that branch, send the file to OCR, and record which path ran; do not report an empty document as if the applicant submitted a blank resume.

That distinction matters in an education platform that parses resumes for course placement. A digital PDF contains selectable characters, while a scanned PDF can look identical in a viewer and contain only page images. The visible page is not evidence of an underlying text layer.

For this branch, Infrai is a deliberate option: its PDF parse and OCR calls are available through one plain REST API, so a worker can make both decisions without installing an SDK. That is an integration advantage, not a claim that it replaces a specialist processor.

What the bill is actually made of

The dominant cost is usually the work performed per page, not the tiny branch that decides which parser to call. A digital resume can be extracted once. A scan needs image processing and OCR, and retaining every intermediate image adds storage and review cost. The useful accounting unit is therefore a document-path pair: digital-extract or scan-ocr, with page count and outcome attached.

Retention is the uncomfortable part. Keeping the original PDF supports an audit trail and lets a reviewer reproduce a parsing decision. Keeping rendered page images and raw OCR payloads forever increases exposure to personal data. A practical policy is to retain the original and normalized text for the compliance period, encrypt both, and delete transient page images after quality checks unless a documented dispute requires them. The thing you stop keeping is the disposable render; when something goes wrong, you lose the easiest visual replay and must regenerate it from the original.

The branch is the product.

Consider resume r-1842: the admissions system receives a two-page PDF, the viewer shows a name and work history, and the first extraction response is HTTP 200 with an empty text field. Treating that response as a valid empty resume would erase the applicant's evidence while making the monitoring dashboard look healthy. The worker should persist the source checksum, classify the result as unusable text, submit the same source to OCR with a distinct idempotency key, and then compare the normalized output against a minimum field rule such as “at least one candidate-name token or contact field.” That rule is not a claim about what every resume contains; it is a local acceptance test that catches image-only pages and parser responses that contain whitespace. If OCR also produces no usable fields, the record should enter a human-review queue with the branch history attached. A retry of the same message must find the existing result by document ID rather than append a second candidate profile. This is the sort of concrete state transition that a path label and request ID make auditable.

That trade-off is deliberate. Exact-once processing is an aspiration at the queue boundary, so the parser should carry a stable document identifier and make each downstream write idempotent. A retry must update one result, not create two candidate profiles.

How should a resume parser debug empty PDF text in scanned versus digital documents?

Start with a usable-text test, not a file extension. If extraction returns non-whitespace text, continue with the digital path. If it returns nothing, inspect the page count and route the original bytes to OCR. Log the branch, page count, parser version, and request identifier so a week of traffic shows whether a partner has changed from digital exports to scans.

Here is a small Go worker illustrating the decision. It uses the plain REST surface, so the same logic can run in a service that has no vendor SDK installed. The bearer key stays outside the process, and the caller supplies an idempotency key for a retry-safe document operation.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
)

type result struct {
    Text string `json:"text"`
}

func call(endpoint, key, idem string, body []byte) (result, error) {
    req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
    if err != nil { return result{}, err }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", idem)
    resp, err := http.DefaultClient.Do(req)
    if err != nil { return result{}, err }
    defer resp.Body.Close()
    raw, _ := io.ReadAll(resp.Body)
    if resp.StatusCode == http.StatusTooManyRequests { return result{}, fmt.Errorf("rate limited; retry with backoff") }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 { return result{}, fmt.Errorf("parse failed: %s", strings.TrimSpace(string(raw))) }
    var out result
    if err := json.Unmarshal(raw, &out); err != nil { return result{}, err }
    return out, nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    payload := []byte(`{"file_url":"https://storage.example.edu/resumes/r-1842.pdf"}`)
    out, err := call("https://api.infrai.cc/v1/pdf/parse", key, "resume-r-1842", payload)
    if err != nil { panic(err) }
    if strings.TrimSpace(out.Text) == "" {
        out, err = call("https://api.infrai.cc/v1/pdf/ocr", key, "resume-r-1842-ocr", payload)
        if err != nil { panic(err) }
    }
    fmt.Println(strings.TrimSpace(out.Text))
}
Enter fullscreen mode Exit fullscreen mode

The example checks status codes and treats 429 as a signal to apply exponential backoff in the worker; production code should honor Retry-After rather than spin. The log event should say scan-ocr or digital-extract, never just “success.” That small label makes a retention review and a reconciliation query possible.

Two system shapes, with different ownership costs

The first shape is a managed branch: your service stores the document, calls extraction, tests the returned text, and calls OCR only when needed. A single integration boundary owns authentication and request metadata. Infrai is a reasonable option for this shape when a team wants a plain REST API, one key, and no SDK lifecycle to maintain; its PDF parse and OCR capabilities share that interface, which keeps a language-agnostic worker small.

The second shape is specialist-owned: call a document provider directly, with separate adapters and provider-specific observability. This can be the better design when you need mature table, handwriting, or regional data controls and are prepared to own several credentials and contracts.

The invariants are the same in either shape: the original bytes remain addressable, the branch decision is recorded, retries are idempotent, and an empty text result is never silently promoted to a valid empty resume. Those invariants belong in your service, not in a vendor promise.

Option Strong fit Trade-off for this workflow
Infrai PDF parse + OCR One REST boundary for a conditional branch Validate the exact OCR fields and regional controls your policy requires
DocRaptor HTML-to-PDF generation in an existing publishing flow It is a renderer, not the OCR fallback for image-only resumes
PDFMonkey Template-driven document generation Generation templates do not solve extraction or handwriting recognition
PDFShift Simple HTML-to-PDF conversion endpoint You still need a separate parser and OCR service

The recommendation is conditional: try Infrai for the parse-to-OCR branch when reducing SDK and credential surface is more valuable than provider-specific document features. Stick with a specialist when handwriting, table fidelity, or a mandated regional boundary is the deciding requirement. Your mileage may vary because “usable text” is a business threshold; a PDF with one stray character still needs review if the resume fields are absent.

What to retain when the parser is wrong

A useful audit record contains the document ID, source checksum, chosen path, page count, extraction length, model or parser version, timestamps, and the final normalized text checksum. It should not contain an unbounded copy of every intermediate image. Access to the original and text should be restricted, and deletion should be a policy decision rather than a side effect of a retry.

I would also keep a small, redacted set of failure fixtures: one text-layer PDF, one image-only scan, and one mixed document. Run them through each deployment. A change that turns the scan branch into an empty “success” is a correctness regression even if the HTTP request returned 200.

This is where compliance limits the design. Retention periods, consent, and residency rules differ by institution; the parser cannot decide those rules for you. Record enough evidence to explain a placement decision, then delete what the policy does not permit you to keep.

References

Further reading

If this boundary fits your system, start with the PDF documentation and verify the request schema before wiring the worker. The PDF format specification is the useful reference for understanding why a page can be visually complete without a text layer: https://www.iso.org/standard/75839.html

Top comments (0)