757 lines
36 KiB
Go
757 lines
36 KiB
Go
package main
|
|
|
|
// OCR — the literal words on a picture, read on the USER'S machine.
|
|
//
|
|
// It is the largest remaining gap by volume after PDF
|
|
// text, and it is the one where getting it wrong is worst: a scanned document
|
|
// is images wrapped in a PDF, so pdftotext returns nothing while the page is
|
|
// full of words. A user who uploads a scanned contract and gets back an empty
|
|
// result has been told, silently, that their document says nothing.
|
|
//
|
|
// NOTE the boundary the picture lane drew and this file stays on the right side of:
|
|
// deterministic OCR of literal on-screen text is NOT "describe the picture with
|
|
// a vision model and embed the caption". That remains a user-enabled option and
|
|
// must never be presented as this. Nothing here calls a model.
|
|
//
|
|
// ── THE PROBE RUNS A REAL OCR, and this is the measured reason ──────────────
|
|
//
|
|
// tesseract ships in two pieces on every distro that packages it: the engine
|
|
// and the LANGUAGE DATA. Ubuntu has `tesseract-ocr` and `tesseract-ocr-eng` as
|
|
// separate packages (verified on this box: dpkg -L tesseract-ocr lists no
|
|
// .traineddata at all). A box with the engine and no language data cannot read
|
|
// one word.
|
|
//
|
|
// MEASURED, with TESSDATA_PREFIX pointed at an empty directory:
|
|
//
|
|
// tesseract --version exit 0 <- says nothing about language data
|
|
// tesseract --list-langs exit 0 <- prints a list of ZERO and exits 0
|
|
// tesseract <img> - --psm 7 exit 1 <- the only one that tells the truth
|
|
//
|
|
// So the house probe idiom — run the tool, take the exit code (detectRuntime,
|
|
// harness.go:580) — is NOT sufficient here, because the obvious argv to run
|
|
// exits 0 on a machine that cannot OCR. Both cheap probes are PROXIES for the
|
|
// capability; the capability itself is "can this engine turn a picture of a
|
|
// word into that word", and the only authority for it is doing exactly that.
|
|
//
|
|
// Hence ocrProbePNG: a 180x44 grey PNG of one known word, OCR'd for real on
|
|
// every probe. 0.05 s measured, cached for prepCacheFor like every other probe.
|
|
// The precedent is the platform's own media capability gate, which embeds a
|
|
// real 16x16 PNG rather than trusting a model's self-description
|
|
// (the host's media engine) — and for the same reason: a
|
|
// capability is what the thing DOES.
|
|
//
|
|
// `osd` is excluded from the language list deliberately. It is orientation and
|
|
// script detection, it ships inside the base `tesseract-ocr` package, and it
|
|
// cannot recognise text — so a box with only osd.traineddata reports "1
|
|
// language" and still OCRs nothing. Counting it would rebuild the exact bug
|
|
// this file exists to prevent.
|
|
|
|
import (
|
|
_ "embed"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ocrProbePNG is a 1,975-byte grey PNG reading one word, generated by
|
|
// rasterizing a one-line PostScript page at 72 dpi (the smallest resolution at
|
|
// which this build's tesseract still reads it cleanly — 72/100/150 dpi were all
|
|
// tried and all read it, so the smallest was taken). It is never stored, never
|
|
// sent anywhere, and exists only to be OCR'd.
|
|
//
|
|
//go:embed ocrprobe.png
|
|
var ocrProbePNG []byte
|
|
|
|
// ocrProbeWord is what the probe image says. Required back from the engine when
|
|
// the box is OCRing in English, which is the case on the overwhelming majority
|
|
// of machines and the only case where a specific answer is safe to demand: a
|
|
// Latin-script model reads this correctly whatever language it was trained for,
|
|
// but a model trained on a non-Latin script has no honest obligation to.
|
|
const ocrProbeWord = "PRIVATAE"
|
|
|
|
// ocrProbePSM is "treat the image as a single text line" — right for a
|
|
// one-word image, and NOT what real extraction uses (that gets the default
|
|
// full-page segmentation). The probe is proving the engine loads and
|
|
// recognises, not that page layout analysis works.
|
|
const ocrProbePSM = "7"
|
|
|
|
// ocrLangOSD is tesseract's orientation/script detector. It appears in
|
|
// --list-langs, it is not a recognition language, and it arrives with the
|
|
// engine rather than with a language pack. See the header.
|
|
const ocrLangOSD = "osd"
|
|
|
|
// ocrDPI is what a scanned PDF's pages are rasterized at before OCR.
|
|
//
|
|
// 300 is the number every production document pipeline assumes: it is
|
|
// tesseract's own documented floor for reliable recognition, it is what AWS
|
|
// Textract, Google Document AI and Azure Document Intelligence all state as the
|
|
// resolution they expect scanned input at, and 8.4 megapixels for a 300-dpi
|
|
// letter page is the figure OCRmyPDF uses when it reasons about page size.
|
|
//
|
|
// MEASURED on this box, on a real image-only PDF whose embedded scan is 200 dpi
|
|
// (ghostscript -sDEVICE=pdfimage24), one letter page:
|
|
//
|
|
// 150 dpi 1275x1650 0.36 s same text, every line exact
|
|
// 200 dpi 1700x2200 0.44 s same text, every line exact
|
|
// 300 dpi 2550x3300 2.18 s same text, every line exact
|
|
// 400 dpi 3400x4400 2.39 s same text, every line exact
|
|
//
|
|
// Accuracy was identical at all four — on CLEAN synthetic input, which is the
|
|
// limitation of that measurement and the reason it does not decide the number.
|
|
// Real scans carry sensor noise, skew and JPEG ringing, which is exactly the
|
|
// regime where resolution stops being free, and the published floor is 300.
|
|
// Six times the CPU for a document the user will read once is the cheap side of
|
|
// that trade; a wrong transcription of their contract is not.
|
|
const ocrDPI = "300"
|
|
|
|
// ocrPageCap is how many pages of one scanned PDF a single job OCRs.
|
|
//
|
|
// This lane is SYNCHRONOUS — one management dispatch, one answer — so the cap
|
|
// is what keeps a 400-page scan from holding a worker on the user's own machine
|
|
// for twenty minutes. Production draws the same line in the same place and
|
|
// draws it LOWER: Google Document AI accepts 15 pages for online processing
|
|
// (and 500 in batch), AWS Textract accepts ONE page synchronously for PDF (3000
|
|
// asynchronously), Azure Document Intelligence 2 pages on its free tier. 30 is
|
|
// twice the most generous of those, and it covers the documents a person
|
|
// actually scans: an invoice, a receipt, a letter, an ID, a contract.
|
|
//
|
|
// It is REPORTED, never silent: the reply says how many pages were read and
|
|
// that there were more, so a user is never left to infer that page 31 was
|
|
// blank. Raising it is a change to ocrExtractTimeout as well — the two are one
|
|
// decision, and ocrPerPageBudget is the arithmetic between them.
|
|
const ocrPageCap = 30
|
|
|
|
// ocrPerPageBudget is the time one page is allowed to cost, and it is where
|
|
// ocrExtractTimeout comes from rather than being a second guess at the same
|
|
// number. MEASURED above at 2.18 s wall for a 300-dpi letter page plus 0.55 s
|
|
// to rasterize it; 4 s is that with room for a machine slower than this one,
|
|
// which a bastion frequently is.
|
|
const ocrPerPageBudget = 4 * time.Second
|
|
|
|
// ocrSetupBudget is the fixed cost before per-page work begins: process start,
|
|
// the PDF's own parse, and the text-layer pass that decides which pages need
|
|
// OCR at all.
|
|
const ocrSetupBudget = 30 * time.Second
|
|
|
|
// ocrExtractTimeout bounds a whole pdf-ocr job. Derived, not chosen:
|
|
// 30 + 30x4 = 150 s.
|
|
const ocrExtractTimeout = ocrSetupBudget + ocrPageCap*ocrPerPageBudget
|
|
|
|
// ocrImageTimeout bounds a single-image OCR. One picture, no rasterizing, and
|
|
// measured at 0.64 s for a full 300-dpi page — 30 s is the allowance for an
|
|
// unusually large scan on an unusually slow machine, not an expected cost.
|
|
const ocrImageTimeout = 30 * time.Second
|
|
|
|
// ocrPageHasText is how few characters a page's TEXT LAYER may hold before
|
|
// pdf-ocr gives that page to the engine as well and compares the two readings.
|
|
//
|
|
// IT IS A BUDGET, NOT A CLASSIFIER, and that is the correction. It used to be
|
|
// 16, chosen on the claim that there is "no ambiguous middle" between a scanned
|
|
// page and a text page. There is, it is common, and it is a STAMP: a copier
|
|
// footer, a fax TTI header, a Bates number or an e-signature block writes a few
|
|
// real characters into the text layer of a page that is otherwise a picture.
|
|
// Under 16 such a page read as "this page has text" and its body never reached
|
|
// the engine, so the user got the name of the app that scanned their contract
|
|
// and none of their contract.
|
|
//
|
|
// THE TWO POPULATIONS OVERLAP, so no character count separates them. MEASURED
|
|
// on this box:
|
|
//
|
|
// SPARSE BUT GENUINE TEXT PAGES — 203 pages of 9 real documents (a published
|
|
// specification and eight papers), chars per page through pdftotext -layout:
|
|
// minimum 48 (a title page), then 110, 116, 268, 344, 364; median 2,340.
|
|
//
|
|
// A STAMP OVER A SCAN — 11 real stamp/header/footer formats written over an
|
|
// image-only page: 12 (CONFIDENTIAL), 18 (a Bates number), 19, 21 (a scanner
|
|
// app footer — the reported case), 35, 36, 57 (an ITU-T T.30 fax TTI header),
|
|
// 58 (a DocuSign envelope footer), 61, 62, 99 (a three-line e-sign audit
|
|
// block). Minimum 12, median 36, maximum 99.
|
|
//
|
|
// 48 < 99, so the populations overlap and every threshold misclassifies one side
|
|
// or the other. Apache Tika, the closest production precedent for exactly this
|
|
// decision, sets its equivalent to 10 (OcrConfig.java, StrategyAuto.BETTER =
|
|
// new StrategyAuto(10, 10); "if the total characters on the page <
|
|
// totalCharsPerPage ... then OCR will be performed") — below ours, and so it
|
|
// does not separate them either.
|
|
//
|
|
// WHAT DOES SEPARATE THEM is not the count, it is the ratio between the two
|
|
// readings of the SAME page, which is why this number no longer has to be a
|
|
// classifier: see ocrBeatsLayer. This one only decides who gets a second
|
|
// opinion, and the errors either side of it are:
|
|
//
|
|
// too HIGH -> a sparse but genuine text page is rendered and OCR'd for
|
|
// nothing. Cost: CPU, measured below, and no wrong text, because
|
|
// ocrBeatsLayer decides what is kept.
|
|
// too LOW -> a stamped scan is declared read. Cost: the user is told their
|
|
// document says "Scanned by CamScanner". That is the single worst
|
|
// outcome in this lane and the reason the lane exists.
|
|
//
|
|
// THE NUMBER, derived rather than picked. It must clear the whole measured stamp
|
|
// band (maximum 99) with room, and stop below the point where margin text stops
|
|
// being a margin artifact. A stamp is at most three lines — the most verbose
|
|
// real format measured, an e-signature audit block, is three — and a full line
|
|
// across a letter page's 6.5-inch printable width holds 104 characters at 8 pt,
|
|
// the smallest size a stamp is realistically set in (measured with ghostscript
|
|
// `stringwidth` against real Helvetica metrics: 104.2 at 8 pt, 92.6 at 9, 83.4
|
|
// at 10, 69.5 at 12). Three full lines at 8 pt is 312.
|
|
//
|
|
// THE CPU IT SPENDS, measured on the same 203 real pages, at 0.49 s to render a
|
|
// page at 300 dpi plus 2.18 s to OCR it:
|
|
//
|
|
// band pages in band extra on a 20-page text document
|
|
// 16 0 / 203 (0.0%) 0.00 s
|
|
// 128 3 / 203 (1.5%) 0.79 s
|
|
// 312 4 / 203 (2.0%) 1.05 s
|
|
// 512 7 / 203 (3.4%) 1.84 s
|
|
//
|
|
// against the 9.8 s of unconditional rendering the same document paid before the
|
|
// layer-first restructure below. So the widening costs about a ninth of what the
|
|
// restructure gives back, and a text document is still far cheaper than it was.
|
|
const ocrPageHasText = 312
|
|
|
|
// ocrBeatsLayer is how many times longer the engine's reading of a page must be
|
|
// before it is kept in place of the page's own text layer.
|
|
//
|
|
// A MULTIPLE RATHER THAN A CHARACTER, because widening ocrPageHasText is what
|
|
// makes the multiple necessary. The band now admits genuinely sparse text pages,
|
|
// and on those the layer and its OCR come out close in length, so "keep
|
|
// whichever is longer" is a coin flip decided by noise. MEASURED on
|
|
// six real sparse pages from real documents (48 to 364 characters of layer), the
|
|
// OCR-to-layer length ratio was 0.92, 1.08, 0.10, 0.81, 0.84 and 0.42 — and at
|
|
// 1.08 the length rule DID flip one: a 110-character layer lost to a
|
|
// 119-character OCR that kept 11 of the layer's 16 words and invented 8 the page
|
|
// does not contain.
|
|
//
|
|
// On the other side, the same measurement over five real stamp formats written
|
|
// on a full-page scan (2,530 characters of body) gave 26.7, 44.9, 45.7, 121.6
|
|
// and 141.7. Nothing measured sits between 1.08 and 26.7, so 2 is inside a gap
|
|
// 25 times wide: it is 1.85x above the highest genuine-text ratio and 13x below
|
|
// the lowest stamped-scan ratio.
|
|
//
|
|
// WHAT IT STILL GETS WRONG, stated rather than glossed: a scan so sparse that
|
|
// its body OCRs to less than twice its stamp — a receipt with a long footer —
|
|
// keeps the stamp. Length is a proxy either way; this narrows the window in
|
|
// which the proxy is uninformative rather than closing it.
|
|
const ocrBeatsLayer = 2
|
|
|
|
// ocrLangs is every recognition language this machine's engine can actually
|
|
// load, sorted, with osd excluded. Empty means the engine is installed and has
|
|
// no language data — a capability answer, not an error.
|
|
func ocrLangs() []string {
|
|
out, err := runCmd(5*time.Second, "tesseract", "--list-langs")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// The first line is a header ("List of available languages in ..."); the
|
|
// rest are the language codes, one per line, on STDOUT (measured — unlike
|
|
// pdftotext's version banner, which is on stderr).
|
|
langs := []string{}
|
|
for i, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if i == 0 && strings.Contains(line, "available languages") {
|
|
continue
|
|
}
|
|
if line == "" || line == ocrLangOSD || strings.Contains(line, " ") {
|
|
continue
|
|
}
|
|
langs = append(langs, line)
|
|
}
|
|
sort.Strings(langs)
|
|
return langs
|
|
}
|
|
|
|
// ocrLang is the language this box OCRs in: English when it has it, otherwise
|
|
// the first it does have.
|
|
//
|
|
// One language, not all of them joined with '+'. tesseract accepts `-l eng+deu`
|
|
// and is slower and less accurate for it, and a box with five packs installed
|
|
// would pay that on every page for no one's benefit. Which one it chose travels
|
|
// to the host in the tool report, so nothing about the choice is hidden — and
|
|
// the box's whole language list travels too, so a later slice can let the host
|
|
// name one.
|
|
func ocrLang() string {
|
|
langs := ocrLangs()
|
|
for _, l := range langs {
|
|
if l == "eng" {
|
|
return "eng"
|
|
}
|
|
}
|
|
if len(langs) > 0 {
|
|
return langs[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ocrProve is the capability decision for every OCR job: read the known word
|
|
// off the embedded image, for real, with this box's own language.
|
|
//
|
|
// Returns (false, reason) rather than (false, "") on purpose — "tesseract is
|
|
// here and has no language data" is a DIFFERENT answer from "there is no
|
|
// tesseract", the user fixes them with different commands, and the scan report
|
|
// carries the distinction to the host. That is the quiet-vs-blind rule this
|
|
// heartbeat already applies three times (agents.go:423-444) applied to a tool
|
|
// instead of to a scan.
|
|
func ocrProve() (bool, string) {
|
|
lang := ocrLang()
|
|
if lang == "" {
|
|
return false, "tesseract is installed but has no language data — it cannot " +
|
|
"read a single word until a language pack is installed (on Debian/Ubuntu " +
|
|
"that is a separate package, e.g. tesseract-ocr-eng; elsewhere it is a " +
|
|
".traineddata file in the tessdata directory)"
|
|
}
|
|
dir, err := os.MkdirTemp("", "ocrprobe-")
|
|
if err != nil {
|
|
return false, "tesseract is installed but there was no scratch space to prove it works: " + err.Error()
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
img := filepath.Join(dir, "probe.png")
|
|
if err := os.WriteFile(img, ocrProbePNG, 0o600); err != nil {
|
|
return false, "tesseract is installed but the probe image could not be written: " + err.Error()
|
|
}
|
|
out, err := runCmd(15*time.Second, "tesseract", img, "-", "-l", lang, "--psm", ocrProbePSM)
|
|
if err != nil {
|
|
return false, fmt.Sprintf("tesseract is installed with language %q but failed to "+
|
|
"read a one-word test image — the engine cannot OCR on this machine", lang)
|
|
}
|
|
got := strings.TrimSpace(out)
|
|
if got == "" {
|
|
return false, fmt.Sprintf("tesseract ran with language %q and returned nothing for a "+
|
|
"one-word test image — the engine is not recognising text on this machine", lang)
|
|
}
|
|
// The strict check, applied only where it is safe. A Latin-script engine
|
|
// reads this word whatever language it was trained for; a non-Latin one has
|
|
// no obligation to, so demanding it there would refuse a box that works.
|
|
if lang == "eng" && !strings.Contains(strings.ToUpper(got), ocrProbeWord) {
|
|
return false, fmt.Sprintf("tesseract read %q from a test image that says %q — "+
|
|
"the English language data on this machine is not working", firstLine(got), ocrProbeWord)
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// ocrPSMAuto is "automatic page segmentation WITH orientation and script
|
|
// detection" — tesseract's psm 1, and the one flag that makes an upside-down
|
|
// scan readable.
|
|
//
|
|
// WHY IT IS HERE. A duplex feeder routinely puts pages through the other way up,
|
|
// and until this flag a 180-degree page went into the user's corpus as garbage
|
|
// with nothing anywhere saying so. MEASURED on this box (tesseract 5.5.0,
|
|
// leptonica 1.86.0), a 300-dpi raster of a six-line letter page:
|
|
//
|
|
// rotation default psm (3) --psm 1
|
|
// 0 exact EXACTLY THE SAME BYTES
|
|
// 90 exact (layout analysis fixes it) exact
|
|
// 180 "'y yBnoswuy) ind sem yeu]…" exact
|
|
// 270 "MO|9q pe]uULd SSeippe…" exact
|
|
//
|
|
// WHY THIS RATHER THAN A --psm 0 PRE-PASS, which is the other way to do it:
|
|
// psm 0 is a second exec per page that reports an orientation and leaves the
|
|
// caller to rotate the raster and pick a confidence floor. Measured, the floor
|
|
// is the problem — across seven real pages the confidence numbers do not
|
|
// separate the cases at all (genuinely upright pages scored 1.32, 2.18, 12.34
|
|
// and 40.10; genuinely rotated ones scored 1.58, 5.44, 6.24 and 12.40), so any
|
|
// threshold picked from this evidence would be a number with nothing behind it,
|
|
// and a misfire ROTATES A PAGE THAT READS FINE TODAY. psm 1 hands that judgment
|
|
// to tesseract's own internal threshold, which is the authority for it.
|
|
//
|
|
// WHAT IT DOES NOT FIX, stated rather than glossed: on a page with very little
|
|
// text — a receipt, a page with two lines on it — OSD answers "Too few
|
|
// characters. Skipping this page" and the page is read upright, exactly as it is
|
|
// today. So this is a strict improvement and never a regression, and a sparse
|
|
// upside-down page is still garbage. Fixing that one needs a confidence floor
|
|
// this measurement cannot supply.
|
|
//
|
|
// COST: +0.17 s per page measured (1.88 s to 2.72 s over five runs of the same
|
|
// page), against ocrPerPageBudget of 4 s.
|
|
//
|
|
// A BOX WITHOUT osd.traineddata STILL WORKS. Measured with TESSDATA_PREFIX
|
|
// pointed at a directory holding only eng: tesseract prints "Warning: Auto
|
|
// orientation and script detection requested, but osd language failed to load"
|
|
// on stderr, reads the page anyway, and EXITS 0. osd ships inside the base
|
|
// tesseract package on Debian/Ubuntu rather than with a language pack, so the
|
|
// common case is that it is there; the uncommon one degrades to today's
|
|
// behaviour instead of failing.
|
|
const ocrPSMAuto = "1"
|
|
|
|
// ocrArgv reads one image file. `-` as the output base is tesseract's spelling
|
|
// of stdout (verified against the real binary, not documentation).
|
|
func ocrArgv(in string) []string {
|
|
lang := ocrLang()
|
|
if lang == "" {
|
|
// Cannot happen through the advertised path (the probe refuses first),
|
|
// and if it somehow does, tesseract's own default is eng and its own
|
|
// error message is better than a fabricated one.
|
|
lang = "eng"
|
|
}
|
|
return []string{in, "-", "-l", lang, "--psm", ocrPSMAuto}
|
|
}
|
|
|
|
// ── pdf-ocr: the scanned half, and why it is ONE job and not a fallback ─────
|
|
//
|
|
// A scanned PDF is images wrapped in a PDF. pdftotext returns nothing for it
|
|
// while the page is covered in words, so SOMETHING has to notice and reach for
|
|
// the engine. There were three places to put that decision and only one of them
|
|
// survives contact with the rest of this lane:
|
|
//
|
|
// THE BOX SILENTLY UPGRADES pdf-text INTO OCR. Refused. The host asked for
|
|
// one job and would be billed a hundred times the CPU for another, on the
|
|
// user's machine, with `extracted_by` recording something that did not
|
|
// happen. A box that does work it was not asked for is the same dishonesty as
|
|
// a box that advertises work it cannot do.
|
|
//
|
|
// THE HOST RUNS pdf-text, SEES EMPTY, AND RE-DISPATCHES OCR. Refused, and for
|
|
// a concrete reason rather than a stylistic one: the pull ticket is
|
|
// SINGLE-USE and bound to one machine (the host's ticket store), so
|
|
// a second attempt means a second ticket and a second download of the same
|
|
// document — up to 64 MiB pulled twice over the user's own link to read it
|
|
// once.
|
|
//
|
|
// THE HOST ASKS FOR THE JOB IT WANTS, BY NAME. Taken. `pdf-ocr` is a distinct
|
|
// advertised job whose contract is "this PDF's text, reading the pages that
|
|
// have none" — text layer first, engine only where the text layer is empty.
|
|
// The host asks for it when the machine advertises it, gets it in one
|
|
// dispatch on one ticket, and the name it asked for is the name that ran.
|
|
//
|
|
// The honesty this buys is the point: a box advertises pdf-ocr only when it has
|
|
// BOTH poppler's rasterizer and an engine that provably reads a word, so the
|
|
// host never offers OCR on a machine that would refuse it, and a machine with
|
|
// poppler alone still offers pdf-text. The three answers a user can get stay
|
|
// three different sentences — no OCR here, an engine with no language data, and
|
|
// a document whose pages really are blank.
|
|
//
|
|
// ── THE ORDER: TEXT LAYER FIRST, and the reason the old order was wrong ─────
|
|
//
|
|
// This job used to render every page before it read a single character, and the
|
|
// justification written here was that the extra rendered page doubled as the
|
|
// page-count probe, so pdf-ocr needed two binaries instead of three. That
|
|
// justification did not survive being measured. MEASURED with poppler
|
|
// 26.01.0, one two-page text document, medians of ten and five runs:
|
|
//
|
|
// pdftotext -layout -q -f 1 -l 31 -- doc.pdf - 6.2 ms exit 0, exactly 2 form feeds
|
|
// pdftoppm -png -gray -r 300 -f 1 -l 3 doc.pdf 978.5 ms 2 PNGs
|
|
//
|
|
// pdftotext takes the SAME cap+1 bound, exits 0 when asked for more pages than
|
|
// the document has, and emits exactly one form feed per real page — including
|
|
// for an image-only PDF, which comes back as form feeds and nothing else. So the
|
|
// layer pass carries the page count for free, on a binary this job already runs,
|
|
// and no third binary is needed either way. The old order taxed every
|
|
// born-digital document roughly 160x for a count it could have had for 6 ms.
|
|
//
|
|
// The order now is: read the layer for cap+1 pages, take the count from it, and
|
|
// render ONLY the pages whose layer is under ocrPageHasText. A text document
|
|
// renders nothing at all. A scan does exactly what it did before plus one 6 ms
|
|
// layer pass, because none of its pages has a layer to keep.
|
|
//
|
|
// WHEN POPPLER WILL NOT ANSWER AT ALL, the count goes with the text — so there
|
|
// is a fallback to the old render probe, and only then. See renderPageProbe.
|
|
|
|
// ocrPageResult is one page of a scanned PDF and how its text was obtained.
|
|
type ocrPageResult struct {
|
|
Page int `json:"page"`
|
|
How string `json:"how"` // "text" (the PDF's own layer) or "ocr"
|
|
Text string `json:"-"`
|
|
}
|
|
|
|
// runPDFOCR is the pdf-ocr job: the PDF's text layer where it has one, the OCR
|
|
// engine where it does not.
|
|
//
|
|
// Returns (text, timedOut, err) to match runExtract, plus the per-page record
|
|
// so the reply can say what was actually done rather than leaving the user to
|
|
// infer it.
|
|
func runPDFOCR(root, in string, deadline time.Time) (string, []ocrPageResult, int, bool, error) {
|
|
return runPDFOCRCapped(root, in, deadline, ocrPageCap)
|
|
}
|
|
|
|
// runPDFOCRCapped is runPDFOCR with the page cap passed in, so a test can drive
|
|
// the more-pages path against a two-page document instead of building and OCRing
|
|
// a thirty-one page one. The cap is the only thing that varies; every other
|
|
// decision in here is the one production takes.
|
|
func runPDFOCRCapped(root, in string, deadline time.Time, pageCap int) (string, []ocrPageResult, int, bool, error) {
|
|
dir, err := os.MkdirTemp(root, "ocrpages-")
|
|
if err != nil {
|
|
return "", nil, 0, false, fmt.Errorf("no scratch space for the pages: %w", err)
|
|
}
|
|
// The user's document, one page at a time, on their own disk. Gone before
|
|
// the reply, whatever happened — including a panic, which unwinds through
|
|
// this.
|
|
defer os.RemoveAll(dir)
|
|
|
|
// THE TEXT LAYER FIRST, for ONE PAGE MORE than the cap.
|
|
//
|
|
// The cap+1 trick is unchanged; only the pass it rides on has moved. The
|
|
// layer pass answers "how many pages" from the number of form feeds it
|
|
// emits, which it does for a scanned document too — an image-only PDF comes
|
|
// back as one form feed per page and nothing else (measured). So the count
|
|
// costs nothing extra and no page has to be rendered to obtain it.
|
|
layer, layerRead := pdfTextByPage(in, pageCap+1, deadline)
|
|
|
|
var (
|
|
rendered []string // pre-rendered pages, only on the fallback path below
|
|
total int
|
|
more bool
|
|
)
|
|
if layerRead {
|
|
total = len(layer)
|
|
if total > pageCap {
|
|
more = true
|
|
total = pageCap
|
|
layer = layer[:pageCap]
|
|
}
|
|
} else {
|
|
// POPPLER WOULD NOT READ THE LAYER, so there is no page count either and
|
|
// the job falls back to the render probe this used to do unconditionally.
|
|
// Every page then goes to the engine, which is the safe direction and
|
|
// exactly today's behaviour for a document with no readable layer.
|
|
layer = nil
|
|
rendered, more, err = renderPageProbe(dir, in, pageCap, deadline)
|
|
if err != nil {
|
|
return "", nil, 0, false, err
|
|
}
|
|
if rendered == nil {
|
|
return "", nil, 0, true, nil // out of time before anything was rendered
|
|
}
|
|
total = len(rendered)
|
|
}
|
|
if total == 0 {
|
|
return "", nil, 0, false, fmt.Errorf("poppler found no pages in this PDF")
|
|
}
|
|
|
|
results := make([]ocrPageResult, 0, total)
|
|
// BOUNDED AS IT ACCUMULATES, for the reason runExtract's header records:
|
|
// runExtract caps each PAGE at ctxExtractMaxText, but thirty of those added
|
|
// together is thirty times the cap held in one process on the user's machine.
|
|
// Past the ceiling the pages keep being READ and counted — the page record
|
|
// stays honest — and their text stops being kept, which the reply then
|
|
// reports as truncated exactly as it does for a single oversize document.
|
|
b := &capWriter{max: ctxExtractMaxText + 1}
|
|
for i := 0; i < total; i++ {
|
|
if time.Now().After(deadline) {
|
|
// OUT OF TIME IS NOT AN EMPTY DOCUMENT. Report what was read and
|
|
// say the clock ran out; the caller turns that into a partial
|
|
// answer rather than a refusal, because the pages already read are
|
|
// the user's and throwing them away helps nobody.
|
|
return b.buf.String(), results, total, true, nil
|
|
}
|
|
if len(strings.TrimSpace(layerAt(layer, i))) >= ocrPageHasText {
|
|
// Above the band: this page's own text is the answer and it is
|
|
// never rendered. On a born-digital document that is every page.
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "text"})
|
|
b.Write([]byte(layer[i] + "\n"))
|
|
continue
|
|
}
|
|
// Inside the band, so this page gets a second opinion — which means it
|
|
// has to be rendered now, one page at a time. Rendering per page rather
|
|
// than in one pass costs ~8 ms of process start per page (measured: 493
|
|
// ms for one page, 978 ms for two, at 300 dpi) against the ~490 ms the
|
|
// page itself costs, and it is what lets a document render only the
|
|
// pages that need it rather than all of them.
|
|
p := ""
|
|
if i < len(rendered) {
|
|
p = rendered[i]
|
|
} else {
|
|
pageBudget := time.Until(deadline)
|
|
if pageBudget <= 0 {
|
|
return b.buf.String(), results, total, true, nil
|
|
}
|
|
p, err = renderOnePage(dir, in, i+1, pageBudget)
|
|
if err != nil {
|
|
// This page could not be drawn. NOT the document's failure and
|
|
// not a blank page: keep whatever layer it had, say the page was
|
|
// not read when it had none, and carry on with the rest — which
|
|
// are the user's and are fine.
|
|
if l := strings.TrimSpace(layerAt(layer, i)); l != "" {
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "text"})
|
|
b.Write([]byte(layer[i] + "\n"))
|
|
continue
|
|
}
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "unread"})
|
|
continue
|
|
}
|
|
}
|
|
// ONE PAGE CANNOT EAT THE WHOLE DOCUMENT'S BUDGET. Bounded at three
|
|
// times the per-page allowance rather than at all the time remaining:
|
|
// a single pathological page — a full-bleed photograph the engine
|
|
// grinds over — would otherwise consume the 150 s and every page after
|
|
// it would be reported as "ran out of time", which blames the document
|
|
// for one page's cost. Whichever is smaller still applies, so the
|
|
// overall deadline is never exceeded.
|
|
pageBudget := 3 * ocrPerPageBudget
|
|
if left := time.Until(deadline); left < pageBudget {
|
|
pageBudget = left
|
|
}
|
|
out, pageTimedOut, oerr := runExtract(pageBudget, "tesseract", ocrArgv(p)...)
|
|
text := strings.TrimSpace(out)
|
|
if pageTimedOut && text == "" {
|
|
// NOT READ, and said so — distinct from a blank page, which is the
|
|
// whole distinction this lane is built around. The document keeps
|
|
// going: the other pages are fine and they are the user's.
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "unread"})
|
|
continue
|
|
}
|
|
// KEEP THE TEXT LAYER UNLESS THE ENGINE FOUND SEVERAL TIMES MORE. This is
|
|
// the whole comparison, and it runs on every page rather than only on the
|
|
// pages where OCR came back empty.
|
|
//
|
|
// It used to run only on the empty case, while the constant above and
|
|
// this paragraph both promised the comparison. A page with a short real
|
|
// text layer — a reference code, a stamp, a caption — and a page image
|
|
// the engine reads badly would then have its REAL characters thrown away
|
|
// for the engine's worse guess, and the record said pages_ocr=1 as though
|
|
// a choice had been made. Reproduced: a 13-character layer against an
|
|
// 8-character OCR kept the 8 and discarded the 13.
|
|
//
|
|
// LENGTH IS THE MEASURE, and it is a proxy rather than a truth — there is
|
|
// no way on this box to ask which of two strings is the better reading of
|
|
// a page. The MULTIPLE is what makes the proxy usable across the whole
|
|
// band this now admits: see ocrBeatsLayer for the two measured
|
|
// populations it sits between. A page whose layer is empty has nothing to
|
|
// compare against, so any reading wins it.
|
|
if oerr != nil || len(text) < ocrBeatsLayer*len(strings.TrimSpace(layerAt(layer, i))) {
|
|
if l := strings.TrimSpace(layerAt(layer, i)); l != "" {
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "text"})
|
|
b.Write([]byte(layer[i] + "\n"))
|
|
continue
|
|
}
|
|
// A genuinely blank page. Recorded as OCR'd, contributing nothing —
|
|
// which is what makes "the engine read 12 pages and they are blank"
|
|
// a different answer from "this box has no engine".
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "ocr"})
|
|
continue
|
|
}
|
|
if text == "" {
|
|
// The engine read it and there is nothing on it, and neither is there
|
|
// a text layer to fall back to. Same answer as above, reached without
|
|
// pretending a comparison happened.
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "ocr"})
|
|
continue
|
|
}
|
|
results = append(results, ocrPageResult{Page: i + 1, How: "ocr"})
|
|
b.Write([]byte(text + "\n\f"))
|
|
}
|
|
if more {
|
|
// Not the real total, and never presented as one: this build knows
|
|
// "more than the cap" and says exactly that.
|
|
total = -1
|
|
}
|
|
return b.buf.String(), results, total, false, nil
|
|
}
|
|
|
|
// layerAt is page i's text layer, or "" when the layer pass returned fewer pages
|
|
// than the job is reading — which is every page on the fallback path, where
|
|
// there is no layer at all. A helper rather than an inline bounds check because
|
|
// the comparison above reads it twice and an off-by-one in one of the two reads
|
|
// would silently turn the comparison back off.
|
|
func layerAt(layer []string, i int) string {
|
|
if i < 0 || i >= len(layer) {
|
|
return ""
|
|
}
|
|
return layer[i]
|
|
}
|
|
|
|
// renderOnePage draws ONE page of the PDF into dir and returns the PNG's path.
|
|
//
|
|
// Its own output prefix per page, because pdftoppm pads the page number in the
|
|
// filename to the width of the DOCUMENT's page count — page 5 of a 12-page file
|
|
// is p5-05.png — so a shared prefix would need that width guessed. A prefix that
|
|
// is unique per page makes the glob exact whatever poppler decides to call it.
|
|
func renderOnePage(dir, in string, page int, budget time.Duration) (string, error) {
|
|
prefix := filepath.Join(dir, fmt.Sprintf("pg%d", page))
|
|
_, rerr := runCmd(budget, "pdftoppm", "-png", "-gray", "-r", ocrDPI,
|
|
"-f", fmt.Sprint(page), "-l", fmt.Sprint(page), in, prefix)
|
|
// A NON-ZERO EXIT IS NOT AUTOMATICALLY A FAILURE HERE. pdftoppm complains
|
|
// about damaged objects on PDFs it nonetheless rendered, and refusing a page
|
|
// poppler was willing to draw would be worse than reading it. What decides
|
|
// is whether the page came out.
|
|
got, gerr := filepath.Glob(prefix + "-*.png")
|
|
if gerr != nil || len(got) == 0 {
|
|
if rerr != nil {
|
|
return "", fmt.Errorf("pdftoppm could not rasterize page %d: %w", page, rerr)
|
|
}
|
|
return "", fmt.Errorf("pdftoppm produced nothing for page %d", page)
|
|
}
|
|
sort.Strings(got)
|
|
return got[0], nil
|
|
}
|
|
|
|
// renderPageProbe is the FALLBACK ordering, used only when the layer pass
|
|
// answered with nothing: render up to the cap plus one page, and take the page
|
|
// count from what came out.
|
|
//
|
|
// This is what pdf-ocr used to do on every document. It survives here because
|
|
// the layer pass carries BOTH the text and the page count, so a document poppler
|
|
// will not read the text of leaves the job with no count either — and a page
|
|
// count is not optional, since "there is more of your document past page 30" is
|
|
// something the reply has to be able to say. Rendering is the only other way to
|
|
// obtain it without a third binary.
|
|
//
|
|
// Returns (nil, false, nil) when the deadline is already gone, which the caller
|
|
// reports as a timeout rather than as an empty document.
|
|
func renderPageProbe(dir, in string, pageCap int, deadline time.Time) ([]string, bool, error) {
|
|
budget := time.Until(deadline)
|
|
if budget <= 0 {
|
|
return nil, false, nil
|
|
}
|
|
prefix := filepath.Join(dir, "probe")
|
|
_, rerr := runCmd(budget, "pdftoppm", "-png", "-gray", "-r", ocrDPI,
|
|
"-f", "1", "-l", fmt.Sprint(pageCap+1), in, prefix)
|
|
pages, gerr := filepath.Glob(prefix + "-*.png")
|
|
if gerr != nil || len(pages) == 0 {
|
|
if rerr != nil {
|
|
return nil, false, fmt.Errorf("pdftoppm could not rasterize this PDF: %w", rerr)
|
|
}
|
|
return nil, false, fmt.Errorf("pdftoppm produced no pages from this PDF")
|
|
}
|
|
sort.Strings(pages)
|
|
if len(pages) > pageCap {
|
|
// Remove the probe page before anything reads it: it exists only to
|
|
// answer "is there a page 31", and OCRing it would return text from a
|
|
// page this job is not claiming to have done.
|
|
for _, extra := range pages[pageCap:] {
|
|
_ = os.Remove(extra)
|
|
}
|
|
return pages[:pageCap], true, nil
|
|
}
|
|
return pages, false, nil
|
|
}
|
|
|
|
// pdfTextByPage is the PDF's own text layer, one entry per page, for the first
|
|
// n pages. A page with no layer is the empty string.
|
|
//
|
|
// THE SECOND RETURN IS LOAD-BEARING and is why this is not just a nil slice.
|
|
// Since the layer pass runs first, it is also what the page count comes from, so
|
|
// "poppler answered, and these pages have no text" and "poppler did not answer"
|
|
// are two different situations with two different next steps: the first OCRs
|
|
// every page, the second has no idea how many pages there are and has to fall
|
|
// back to rendering to find out. Collapsing them would silently report a
|
|
// document poppler refused as a zero-page document.
|
|
//
|
|
// BOUNDED AS IT ARRIVES, via runExtract rather than runCmd. This is the first
|
|
// thing every pdf-ocr job now runs, and a measured 3.2 MB PDF whose
|
|
// 20,000 pages share one compressed stream making pdftotext emit ~83 MB. A
|
|
// capped or timed-out read is not a page count either, so both count as "did not
|
|
// answer".
|
|
func pdfTextByPage(in string, n int, deadline time.Time) ([]string, bool) {
|
|
budget := time.Until(deadline)
|
|
if budget <= 0 {
|
|
return nil, false
|
|
}
|
|
out, timedOut, err := runExtract(budget, "pdftotext", "-layout", "-q",
|
|
"-f", "1", "-l", fmt.Sprint(n), "--", in, "-")
|
|
if err != nil || timedOut || len(out) > ctxExtractMaxText {
|
|
return nil, false
|
|
}
|
|
// pdftotext separates pages with a form feed and emits one after the last
|
|
// page, so the split has a trailing empty element to drop.
|
|
parts := strings.Split(out, "\f")
|
|
if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) == "" {
|
|
parts = parts[:len(parts)-1]
|
|
}
|
|
if len(parts) == 0 {
|
|
// Not one form feed, so not one page: poppler produced no output at all.
|
|
return nil, false
|
|
}
|
|
return parts, true
|
|
}
|