DEV Community

UlricDonovan1564
UlricDonovan1564

Posted on

Report PDF with Chart Images in HTML: A 2026 Batch OCR Pipeline

Short answer: for scanned health records, batch the OCR work first, then render a small HTML report with chart images and print that HTML to PDF as a separate, reproducible step. The report should explain throughput and rejected pages; it should never be the system of record for extracted text.

I care about this boundary because a pretty PDF can hide a bad queue. In a healthtech batch, one malformed scan can block thousands of pages, while a retry can create two searchable documents for the same encounter. The operational unit is therefore a document manifest with a stable ID, an OCR result, and a render result. The PDF is an observation of that state.

Keep the first version boring. Store the source scan and normalized text, generate chart data from counters, and embed the resulting PNG in HTML as a data URI. A local image avoids a late network fetch changing the report. It also makes the HTML artifact portable for a later review.

What should a Node.js example report PDF with charts rendered as images in HTML prove?

It should prove three things: how many pages entered the batch, how many produced searchable text, and how many were quarantined for review. A chart is useful when its numbers come from the same manifest that drives retries. It is not evidence that a particular page was read correctly.

The code below is Go because the renderer can be called from any language, including a Node.js worker. The example builds deterministic HTML; a production job can pass it to a pinned Chromium process or another paged-media engine selected during qualification.

package main

import (
    "crypto/sha256"
    "encoding/base64"
    "fmt"
    "html/template"
    "os"
)

type Counts struct {
    Received, OCRSuccess, Quarantined int
}

func pngDataURI(path string) (string, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    return "data:image/png;base64," + base64.StdEncoding.EncodeToString(b), nil
}

func digest(path string) (string, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    h := sha256.Sum256(b)
    return fmt.Sprintf("%x", h[:]), nil
}

func reportHTML(chartPath, manifestPath string, c Counts) (string, error) {
    chart, err := pngDataURI(chartPath)
    if err != nil {
        return "", err
    }
    digest, err := digest(manifestPath)
    if err != nil {
        return "", err
    }
    data := struct {
        Chart, ManifestDigest string
        Counts               Counts
    }{chart, digest, c}
    t := template.Must(template.New("report").Parse(`<!doctype html>
<html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 16mm; }
body { font: 11pt sans-serif; color: #202124; }
img { width: 150mm; height: auto; display: block; }
table { border-collapse: collapse; margin-top: 8mm; }
td { padding: 2mm 8mm 2mm 0; }
</style></head><body>
<h1>OCR batch report</h1>
<img alt="OCR throughput and quarantine counts" src="{{.Chart}}">
<table><tr><td>Received</td><td>{{.Counts.Received}}</td></tr>
<tr><td>OCR success</td><td>{{.Counts.OCRSuccess}}</td></tr>
<tr><td>Quarantined</td><td>{{.Counts.Quarantined}}</td></tr></table>
<p>Manifest SHA-256: {{.ManifestDigest}}</p>
</body></html>`))
    var out string
    buf := &stringWriter{value: &out}
    err = t.Execute(buf, data)
    return out, err
}

type stringWriter struct{ value *string }

func (w *stringWriter) Write(p []byte) (int, error) {
    *w.value += string(p)
    return len(p), nil
}
Enter fullscreen mode Exit fullscreen mode

The important detail is not the template. It is the digest and the explicit counts. Persist the manifest before rendering, and include the renderer name and version in it. If a chart changes because a font or locale changed, the artifact comparison should tell you why.

Where does batch throughput actually disappear?

OCR throughput is usually lost at boundaries: image decoding, queue visibility timeouts, model concurrency, and PDF rendering. Measure each stage separately. A single end-to-end average can look healthy while a retry storm consumes all workers.

Use idempotency keys derived from the source document and page range. A worker may acknowledge a queue message after writing text but before writing its manifest update; the next delivery must safely resume. Keep quarantined pages out of the success denominator, and cap retries with a human review state. That gives operators a useful answer at 03:00: received, completed, retrying, or waiting for review. In one useful fixture, a two-page TIFF is delivered twice, the first attempt times out after OCR, and the second attempt sees the same key: it should reuse the text object, append one manifest transition, and produce exactly one chart increment. I check this with a fake queue and a pinned renderer build in CI, because a green browser screenshot says nothing about duplicate delivery. The check also records the Go toolchain version and the font package digest; those two details explain most “same HTML, different PDF” surprises I have investigated.

One production lesson generalizes: a chart image should be generated from a snapshot, not from a live database query during PDF rendering. Otherwise the HTML and the manifest can describe different batches. I would rather ship a report marked incomplete than silently mix two runs.

It fails fast.

Renderer choices and the catch

Browser printing handles modern HTML and client-side chart libraries, but it adds a browser binary, font management, and a larger attack surface. A declarative PDF engine is easier to sandbox and may be faster for static tables, yet CSS and JavaScript support vary. A server-side chart renderer avoids browser execution for the image itself, at the cost of another artifact to version.

The catch is that this pipeline is not suitable when clinicians need pixel-perfect annotation tools inside the PDF or when the source must remain a legally signed original. Keep the original scan immutable, and use a qualified signing workflow for regulated records. Stick with a browser renderer when the report truly depends on JavaScript layout; choose a declarative engine when deterministic paged media and a small runtime matter more. Your mileage may vary across fonts and language scripts, so qualify with representative de-identified scans.

Start with fixtures: blank pages, skewed pages, multi-page TIFFs, and a scan containing a table. Record page count, text extraction status, render duration, and PDF byte digest. Compare those values in CI. Do not compare only screenshots; a missing text layer can pass a visual review.

At deploy time, pin fonts and the renderer image, reject remote assets, and set a hard wall-clock limit for each batch. Export queue age and quarantine count. Alert on stalled age, not just worker CPU. During an incident, stop retries before they multiply duplicate work, inspect the manifest, and replay only keys whose prior state is unambiguous.

This is the decision rule I use: optimize OCR concurrency until the queue is healthy, then optimize PDF rendering only if it is the measured bottleneck. The PDF is the report, not the data store.

References

Top comments (0)