fix(ocr): keep the printed layout when rebuilding text from word boxes
The parsers are written against `pdftotext -layout`, and every policy-ocr
fixture is a verbatim excerpt of it. The runtime does not use it: it reads
`-bbox-layout` and rebuilds the page from word boxes, and that rebuild
collapsed all white space — no blank lines between blocks, one space
between columns. White space is the only thing marking a cell boundary on
these borderless forms, so the fixtures could not see any of it.
What it cost, on the GMX PVL especificación and the ANA driver's policy:
- `espectBlock` walks a wrapped cell until a blank line. With no blank
line it ran to the end of the page, so the insured's name came back as
the entire first page of the specification.
- `INSURED\s{2,}` and its siblings matched nothing, and the phone that
shares the name cell rode along with it ("PAMELA DENISE WAGONER
Ph.3102001538"), which matches no customer.
- `parseAnaDriverCoverages` splits SUM INSURED from PREMIUM by the
header's own column offsets. Without offsets, every premium was filed
as a sum insured.
So `toVisualRows` now emits a blank line where the reader sees one (a
vertical gap over 1.6 line heights — the two populations measure 0.3-1.1
and 2.1+, so the threshold sits in empty space) and pads each word to its
own column, using one space wherever words merely follow each other so
rounding drift cannot sprinkle false cell boundaries through prose.
Two independent guards, so neither failure can come back silently: the
ANA phone splits on a single space, and the especificación's cell walk is
capped at the one wrap the longest cell on that document actually uses.
Verified against the real PDFs: the especificación reads "EMMER .
KATHLEEN" with all 18 coverages named (they were "(sin nombre)"), and the
ten born-digital gas invoices parse byte-identically to before. The
scanned statements are untouched — they come through tesseract, not this
path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -859,6 +859,15 @@ describe("parsePolicy / ANA driver's policy (licencia)", () => {
|
||||
expect(parsePolicy(tripled).drivers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("splits the phone off the name even without the printed column gap", () => {
|
||||
// The phone shares the name cell, and the only thing marking it off is
|
||||
// white space — which the OCR seam is free to collapse. Depending on the
|
||||
// gap surviving is what put "PAMELA DENISE WAGONER Ph.3102001538" in the
|
||||
// insured field, where it matched no customer.
|
||||
const collapsed = page(ANA_LICENCIA.text.replace(/ {2,}/g, " "));
|
||||
expect(parsePolicy(collapsed).insuredName).toBe("PAMELA DENISE WAGONER");
|
||||
});
|
||||
|
||||
it("drops the four empty driver slots", () => {
|
||||
// Slots 2-5 print an empty NAME and a bare "NONE" licence.
|
||||
expect(p.drivers.map((d) => d.fullName)).toEqual(["PAMELA DENISE WAGONER"]);
|
||||
|
||||
@@ -727,13 +727,22 @@ function parseGmxEspecificacion(page: OcrPage): ParsedPolicy {
|
||||
* value in a right-hand column, so a two-line risk location comes back as the
|
||||
* label line plus a continuation line indented to the same column. A blank
|
||||
* line always terminates the cell.
|
||||
*
|
||||
* The line budget is a guard, not the layout: the longest cell on this
|
||||
* document wraps once. It exists because "walk until the cell ends" is only
|
||||
* as good as the blank line it walks to, and when the OCR seam stopped
|
||||
* emitting those, this returned the whole first page as the insured's name —
|
||||
* a failure with no bad value to notice, just one enormous good one.
|
||||
*/
|
||||
const ESPEC_MAX_WRAP = 3;
|
||||
|
||||
function espectBlock(lines: string[], label: RegExp): string | null {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(label);
|
||||
if (!m?.[1]) continue;
|
||||
const parts = [m[1]];
|
||||
for (let j = i + 1; j < lines.length && lines[j].trim(); j++) {
|
||||
const until = Math.min(lines.length, i + 1 + ESPEC_MAX_WRAP);
|
||||
for (let j = i + 1; j < until && lines[j].trim(); j++) {
|
||||
parts.push(lines[j].trim());
|
||||
}
|
||||
const value = parts.join(" ").replace(/\s+/g, " ").trim();
|
||||
@@ -1889,7 +1898,12 @@ function parseAnaDrivers(lines: string[]): ParsedDriver[] {
|
||||
const raw = scope[at].match(/^\s*\d\.\s*NAME\s*:\s*(.*)$/i)?.[1]?.trim() ?? "";
|
||||
if (!raw) return;
|
||||
|
||||
const phoneAt = raw.match(/\s{2,}Ph\.?\s*([\d()\s.-]{7,})\s*$/i);
|
||||
// One space is enough to split on: the "Ph." marker plus seven digits at
|
||||
// the end of the cell is not something a name does. Requiring the printed
|
||||
// column gap made this depend on the reassembler keeping it, and when that
|
||||
// collapsed the phone rode into `insuredName` ("PAMELA DENISE WAGONER
|
||||
// Ph.3102001538") and no customer matched it.
|
||||
const phoneAt = raw.match(/\s+Ph\.?\s*([\d()\s.-]{7,})\s*$/i);
|
||||
const fullName = (phoneAt ? raw.slice(0, phoneAt.index) : raw).replace(/\s+/g, " ").trim();
|
||||
if (!fullName) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user