fix(ocr): keep the printed layout when rebuilding text from word boxes
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m24s

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:
2026-08-15 11:56:23 -07:00
co-authored by Claude Opus 5
parent ca6432efc8
commit 19864f16f2
4 changed files with 166 additions and 16 deletions
@@ -859,6 +859,15 @@ describe("parsePolicy / ANA driver's policy (licencia)", () => {
expect(parsePolicy(tripled).drivers).toHaveLength(1); 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", () => { it("drops the four empty driver slots", () => {
// Slots 2-5 print an empty NAME and a bare "NONE" licence. // Slots 2-5 print an empty NAME and a bare "NONE" licence.
expect(p.drivers.map((d) => d.fullName)).toEqual(["PAMELA DENISE WAGONER"]); 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 * 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 * label line plus a continuation line indented to the same column. A blank
* line always terminates the cell. * 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 { function espectBlock(lines: string[], label: RegExp): string | null {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(label); const m = lines[i].match(label);
if (!m?.[1]) continue; if (!m?.[1]) continue;
const parts = [m[1]]; 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()); parts.push(lines[j].trim());
} }
const value = parts.join(" ").replace(/\s+/g, " ").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() ?? ""; const raw = scope[at].match(/^\s*\d\.\s*NAME\s*:\s*(.*)$/i)?.[1]?.trim() ?? "";
if (!raw) return; 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(); const fullName = (phoneAt ? raw.slice(0, phoneAt.index) : raw).replace(/\s+/g, " ").trim();
if (!fullName) return; if (!fullName) return;
@@ -7,8 +7,13 @@ import { parseBboxLayout } from "./tesseract.provider";
* that grouping is what left `PERIODO FACTURADO` with no value next to it and * that grouping is what left `PERIODO FACTURADO` with no value next to it and
* every period field empty on a batch whose text was perfectly readable. * every period field empty on a batch whose text was perfectly readable.
*/ */
/**
* Boxes are sized from the text, at 6 units a character: the reassembler now
* reads the space BETWEEN two boxes, so a fixed width would put a fabricated
* gap after every short word and every row would come back column-padded.
*/
function word(x: number, y: number, text: string): string { function word(x: number, y: number, text: string): string {
return `<word xMin="${x}" yMin="${y}" xMax="${x + 20}" yMax="${y + 8}">${text}</word>`; return `<word xMin="${x}" yMin="${y}" xMax="${x + text.length * 6}" yMax="${y + 8}">${text}</word>`;
} }
function doc(...lines: string[]): string { function doc(...lines: string[]): string {
@@ -26,23 +31,57 @@ describe("parseBboxLayout", () => {
it("rejoins a label with the value printed beside it in another flow", () => { it("rejoins a label with the value printed beside it in another flow", () => {
const [page] = parseBboxLayout( const [page] = parseBboxLayout(
doc( doc(
word(20, 100, "PERIODO") + word(45, 100, "FACTURADO:"), word(20, 100, "PERIODO") + word(68, 100, "FACTURADO:"),
word(300, 100.4, "20260630-20260630"), word(300, 100.4, "20260630-20260630"),
padding(), padding(),
), ),
1, 1,
); );
expect(page).not.toBeNull(); expect(page).not.toBeNull();
expect(page!.text).toContain("PERIODO FACTURADO: 20260630-20260630"); expect(page!.text).toMatch(/PERIODO FACTURADO:\s+20260630-20260630/);
}); });
it("keeps genuinely separate lines apart", () => { it("keeps genuinely separate lines apart", () => {
const [page] = parseBboxLayout( const [page] = parseBboxLayout(
doc(word(20, 100, "Cuenta:") + word(80, 100, "0900003463"), word(20, 130, "Nombre:"), padding()), doc(word(20, 100, "Cuenta:") + word(68, 100, "0900003463"), word(20, 130, "Nombre:"), padding()),
1, 1,
); );
expect(page!.text.split("\n")).toContain("Cuenta: 0900003463"); const lines = page!.text.split("\n").map((l) => l.trim());
expect(page!.text.split("\n")).toContain("Nombre:"); expect(lines).toContain("Cuenta: 0900003463");
expect(lines).toContain("Nombre:");
});
/**
* The layout is data. A borderless table separates its cells with nothing
* but white space, so the parsers read a run of spaces as a cell boundary
* (`INSURED\s{2,}`) and a column offset as a column (`SUM INSURED` vs
* `PREMIUM`). Both regressed to nothing when this collapsed every gap to a
* single space, and the fixtures — taken from `pdftotext -layout`, which
* prints the gaps — could not see it.
*/
it("preserves the gap between two cells of a borderless table", () => {
const [page] = parseBboxLayout(
doc(word(20, 100, "INSURED") + word(300, 100, "PAMELA") + word(340, 100, "WAGONER"), padding()),
1,
);
const line = page!.text.split("\n").find((l) => l.includes("INSURED"))!;
expect(line).toMatch(/INSURED\s{2,}PAMELA WAGONER/);
});
it("preserves the blank line between two blocks", () => {
const [page] = parseBboxLayout(
doc(word(20, 100, "Insured"), word(20, 112, "wraps"), word(20, 200, "Next"), padding()),
1,
);
const lines = page!.text.split("\n").map((l) => l.trim());
// The wrapped continuation stays attached; the next block is cut off from
// it, which is what stops a "join until the cell ends" walk running away.
expect(lines.slice(lines.indexOf("Insured"), lines.indexOf("Next") + 1)).toEqual([
"Insured",
"wraps",
"",
"Next",
]);
}); });
it("scales point coordinates into the render's pixel space", () => { it("scales point coordinates into the render's pixel space", () => {
@@ -254,6 +254,23 @@ export function parseBboxLayout(xhtml: string, scale: number): (OcrPage | null)[
* Rows are cut when a word's vertical centre leaves the band established by * Rows are cut when a word's vertical centre leaves the band established by
* the row's first word, which tolerates the sub-pixel baseline differences * the row's first word, which tolerates the sub-pixel baseline differences
* between fonts on one line without merging two genuinely separate lines. * between fonts on one line without merging two genuinely separate lines.
*
* Vertical WHITE SPACE is preserved as a blank line. Rows alone are not the
* whole layout: on a form, the blank between two blocks is what says where a
* cell's wrapped value stops, and dropping it leaves parsers that walk a
* block ("keep joining until the cell ends") running to the end of the page.
* That is not hypothetical — the GMX PVL especificación read its whole first
* page as the insured's name, because the fixtures were taken from
* `pdftotext -layout` (which prints the blanks) while the runtime fed it this
* function's output (which did not).
*
* Horizontal white space is preserved the same way, by padding each word out
* to its own column. The same fixture mismatch bit here: a run of spaces is
* the ONLY thing separating two cells of a borderless table, so ANA's
* `INSURED\s{2,}` label matches and its `SUM INSURED` / `PREMIUM` column
* split (taken from `head.search()` offsets) both need real offsets. Joining
* on one space put every driver's-policy premium in the sum-insured column
* and left the phone glued to the insured's name.
*/ */
function toVisualRows(words: OcrWord[]): string { function toVisualRows(words: OcrWord[]): string {
const centre = (w: OcrWord) => w.top + w.height / 2; const centre = (w: OcrWord) => w.top + w.height / 2;
@@ -281,14 +298,85 @@ function toVisualRows(words: OcrWord[]): string {
} }
if (current.length) rows.push(current); if (current.length) rows.push(current);
return rows const charWidth = estimateCharWidth(words);
.map((r) => const out: string[] = [];
[...r] rows.forEach((r, i) => {
.sort((a, b) => a.left - b.left) if (i > 0 && isBlankBetween(rows[i - 1], r)) out.push("");
.map((w) => w.text) out.push(layoutRow(r, charWidth));
.join(" "), });
) return out.join("\n");
.join("\n"); }
/**
* One row rendered at its printed column offsets.
*
* Words that merely follow one another inside the same cell are separated by
* exactly one space, whatever the column arithmetic says: one `charWidth` for
* a page that mixes fonts leaves a rounding error on every word, and letting
* that accumulate sprinkles `\s{2,}` runs through ordinary prose — which is
* the very thing the parsers read as a cell boundary. Only a gap wide enough
* to be deliberate (more than one blank character) is rendered as one, and
* only there is the word re-anchored to its true column, so the offsets a
* column split depends on stay honest while values stay clean.
*/
function layoutRow(row: OcrWord[], charWidth: number): string {
let line = "";
let right = 0;
for (const w of [...row].sort((a, b) => a.left - b.left)) {
const col = Math.round(w.left / charWidth);
if (!line.length) {
line = " ".repeat(Math.max(0, col));
} else if (w.left - right > charWidth * 1.5) {
line += " ".repeat(Math.max(2, col - line.length));
} else {
line += " ";
}
line += w.text;
right = w.left + w.width;
}
return line.trimEnd();
}
/**
* Width of one character, in the same units the word boxes use.
*
* The median of each word's own width-per-character: robust to the handful of
* oversized headings and to the wide-tracked letterhead, both of which would
* drag a mean. Only words of 3+ characters vote, since a one-character box is
* mostly side bearing. Falls back to a value derived from line height when a
* page has nothing long enough to measure.
*/
function estimateCharWidth(words: OcrWord[]): number {
const samples = words
.filter((w) => w.text.length >= 3 && w.width > 0)
.map((w) => w.width / w.text.length)
.sort((a, b) => a - b);
if (samples.length) return samples[Math.floor(samples.length / 2)];
const heights = words.map((w) => w.height).filter((h) => h > 0);
return heights.length ? Math.max(...heights) / 2 : 1;
}
/**
* Does the space between two consecutive rows read as an empty line?
*
* Measured against the taller of the two rows so a heading and its body text
* are judged on their own scale. On the real documents the two populations do
* not overlap: consecutive lines of one paragraph sit at 0.31.1 line heights
* apart, and anything the reader sees as blank-separated starts at 2.1. The
* threshold is placed in that empty middle, biased high — a missed blank only
* restores today's behaviour, while a spurious one would cut a wrapped value
* short.
*/
function isBlankBetween(prev: OcrWord[], row: OcrWord[]): boolean {
const bottom = Math.max(...prev.map((w) => w.top + w.height));
const top = Math.min(...row.map((w) => w.top));
const unit = Math.max(
...prev.map((w) => w.height),
...row.map((w) => w.height),
);
return unit > 0 && top - bottom > unit * 1.6;
} }
/** /**