---
name: ocr-text-parsing
description: "Parse semi-structured OCR text into typed records using regex — no AI/API needed. Boundary detection, field extraction, OCR error tolerance."
version: 1.1.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows, browser]
metadata:
  hermes:
    tags: [OCR, Parsing, Regex, Data-Extraction, Browser, CPU]
---

# OCR Text → Structured Records (Rule-Based)

When OCR output contains multiple records (appointments, contacts, invoices) and you need to parse them into typed fields without calling an AI API. Runs in-browser or server-side, instant, zero cost.

## When to Use

- Screenshot → OCR → structured form fields
- Multiple records in one image (table, list, stacked cards)
- User says "no API", "use CPU", "make it efficient", "parse locally"
- OCR quality is imperfect (Tesseract artifacts, garbled characters)

## Core Algorithm

**Strip noise → Extract fields → Split → Assign** (strict order):

1. **Strip advisor/tech noise FIRST** — before any field extraction. Remove known advisor names and OCR corruption variants (see Pitfall #10). This prevents advisor text from contaminating customer name or service.
2. **Extract structured fields in order** — Time → Duration → Phone → Email → VIN → Date. Use targeted regex and remove each match from the text. Order matters: email before VIN prevents `garyb9623@gmail.com` digits from being mistaken for VIN characters. Phone before email prevents phone digits from matching email fragments.
3. **Post-extraction cleanup** — strip residual label tokens (`RO`, `VIN`, `os`, `b=`). These are common OCR artifacts that survive field extraction.
4. **Extract OP code + service** — if bracketed codes like `[REP]`, `[23-046:6MX00]` exist, extract them and capture the service text that follows on the same line. Service regex MUST stop at newline (`[^\n\[]*`, not `[^\[]*`) to prevent vehicle info on the next line from being absorbed.
5. **Extract vehicle** — year + make + model (`(\d{4})\s+([A-Za-z]+)\s+([A-Za-z0-9\-]+)`). Fallback: year + make only.
6. **Name from remainder** — first remaining part after stripping leading non-letter characters.
7. **Recurse** — find next phone/VIN in leftover text, walk back to nearest multi-space gap, split, re-enter step 1.

## Key Patterns

### Phone extraction (forgiving formats)
```js
var phoneRe = /\(?\d{3}\)?[\s.\-]*\d{3}[\s.\-]*\d{4}/;
```

### VIN extraction (OCR-tolerant)
```js
// Accept O/I/Q (common OCR errors for 0/1) — normalize after match
var vinRe = /\b[A-HJ-NPR-Z0-9OIQ]{17}\b/i;
function fixVin(v) { return v.toUpperCase().replace(/O/g,'0').replace(/I/g,'1').replace(/Q/g,'0'); }
```

### Email extraction (extract before VIN to prevent false matches)
```js
var emailRe = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
```
Extract email IMMEDIATELY after phone, before VIN. Emails like `garyb9623@gmail.com` contain 4-digit numbers that can false-match phone/date/VIN patterns. Removing the email from the text first prevents these collisions.

### Date extraction (multiple formats → YYYY-MM-DD)
Handle: `2024-06-15`, `06/20/2024`, `6/20/2024`, `Jan 15, 2024`, `January 15 2024`. Month-name lookup with 3-letter prefixes.

### Time extraction (12h + 24h)
Handle: `9:00 AM`, `1:30PM`, `14:00`, `8am`. Convert to 24h `HH:MM`.

### Multi-record boundary detection
- **Table format**: Detect when 3+ lines have consistent word counts. Skip the header line if it contains `name|phone|date|time|service|vehicle|vin`.
- **Row-anchor split (preferred)**: When each record starts with a predictable pattern like `9:00AM 0.5 hrs`, use it as the primary block delimiter instead of blank lines. Find all anchor positions with `RegExp.exec()` in a loop, split the text at each anchor index. This survives OCR horizontal-scan order where blank lines may be absent. Falls back to blank-line splitting if fewer than 2 anchors found.
  ```js
  var rowAnchorRe = /(\d{1,2}):(\d{2})\s*(AM|PM)\s+(\d+(?:\.\d+)?)\s*hrs?/gi;
  var anchors = [];
  while ((am = rowAnchorRe.exec(t)) !== null) anchors.push({ idx: am.index, len: am[0].length });
  if (anchors.length >= 2) { /* split at anchor boundaries */ }
  else { /* fallback: blank-line split */ }
  ```
- **Free-form**: After extracting one record's fields, find the next phone/VIN. Walk back to the last `\s{2,}` gap (or fallback: last 1-2 capitalized words). Split there — everything before belongs to record N, everything after is record N+1.
- **Trailing whitespace trap**: Phones are often preceded by a space that lands in `before` text. Use `\s*$` in the walk-back regex to tolerate it.

### Critical pitfalls

1. **Collapse whitespace AFTER splitting, never before.** If you `/\s+/ → ' '` first, multi-space gaps vanish and you can't detect column boundaries.
2. **Remove fields with `' '` replacement, not `''`.** Empty-string removal concatenates adjacent words and loses boundaries.
3. **VIN regex must accept O/I/Q** — Tesseract turns `0`→`O` and `1`→`I` frequently. Normalize after matching.
4. **Don't use `{0,3}` greedy in walk-back** — `{0,1}` keeps just the customer name with the next record. Larger ranges eat into the previous record's service/notes fields.
5. **Strip all-caps header lines and separator lines** (`------`) before parsing blocks.
6. **⚠️ Blank-line filter destroys block separators.** A filter like `t.split('\n').filter(l => l.trim().length > 0)` removes blank lines, destroying the block separators needed for `t.split(/\n\s*\n/)`. Use `.map()` instead — return `''` for blank lines, strip only unwanted content.
7. **⚠️ Header detection false-positives.** The regex `/\b(name|phone|date|time|vehicle|vin)\b/i` matches "VIN" inside a data line like "2023 Honda RIDGELINE VIN VIN: SEPYK3F70PB011523", causing the parser to strip the entire appointment. Fix: require 3+ header-word matches AND no phone/VIN pattern in the line before stripping.
8. **⚠️ Trailing-space breaks gap-walk regex.** After phone extraction, `before` text often ends with a trailing space. The regex `/\s{2,}(?=\S+(?:\s+\S+){0,1}$)/` fails because `\S+$` can't match. Fix: add `\s*$` tolerance: `/\s{2,}(?=\S+(?:\s+\S+){0,1}\s*$)/`
9. **⚠️ Vehicle detection needs year+make combination.** A part like "Tailgate wiring harness recall. 15,000 2023 Honda RIDGELINE" contains both vehicle and service. Basic detection picks the whole part. Fix: prefer parts with BOTH a year pattern AND a known make word.
10. **⚠️ Advisor+RO prefix contamination.** Shop management screenshots often include advisor names and RO codes: `Rrahman [REP] Service description`. Strip with `^([A-Z][a-z]+)\s+(\[[^\]]+\])\s*(.*)` and capture the RO code in notes. **Strip advisor noise BEFORE field extraction** — if done after, advisor names can bleed into customer name or phone extraction.
    - **OCR corruption variants**: Tesseract frequently garbles advisor names. Strip these known corruptions: `Rrahman`, `Rrshman`, `Grajqevci`, `Grokevel`, and line-noise patterns like `eE 3 zoom`, `Eee om 3 gem`. Use a single `replace()` with alternation: `/\b(?:Rrahman|Rrshman|Grajqevci|Grokevel)\b/gi`.
11. **⚠️ Service regex must stop at newline.** A regex like `\[REP\]\s*([^\[]*)` captures everything until another bracket — including vehicle info on the next line (e.g., "2023 Honda RIDGELINE"). Fix: use `[^\n\[]*` to stop at newlines. Service descriptions and vehicles are always on separate lines in table-formatted screenshots.
12. **⚠️ Newlines must be preserved in block text.** When passing blocks to `parseOne()`, do NOT `.replace(/\n/g, ' ')`. Newlines are the primary separator between service line and vehicle line. Collapsing them into spaces merges vehicle info into service text. Split parts on BOTH `\s{2,}` and `\n`: `/ \s{2,}|\n/`.
13. **⚠️ Junk token accumulation.** After field extraction, residual labels like `RO`, `VIN`, `os`, `b=` remain in the text. Strip them in a dedicated cleanup step before part splitting: `b.replace(/\bVIN\b/gi, ' ').replace(/\bRO\b/g, ' ').replace(/\b(?:os|b=)\b/gi, ' ')`. Without this, `RO` becomes a prefix on customer names and `VIN` contaminates vehicle or service fields.
14. **⚠️ Regex whack-a-mole ceiling.** When you've added more than 15 regex patterns and are still hitting edge cases every session, the format is too variable for rule-based parsing. At that point, switch to a local small LLM (1-3B params, Q4 quant): Qwen2.5-1.5B or Llama-3.2-3B on llama.cpp. The LLM replaces only the `parseWithRules()` step — Tesseract.js still handles OCR in-browser. Setup: `llama-server -hf bartowski/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M -ngl 99` on GPU.

### Cross-block customer name carryover

When OCR places a customer name at the end of one block but the record's data starts the next block (common in stacked card layouts):

```js
// After assigning service text, check for trailing all-caps name
if (appt.serviceType){
    var tn = appt.serviceType.match(/\s+([A-Z]{2,}(?:\s+[A-Z]{2,}){1,2})\s*$/);
    if (tn && tn[1].length > 3){
        appt.serviceType = appt.serviceType.substring(0, appt.serviceType.length - tn[0].length).trim();
        nextName = tn[1]; // carry to next block
    }
}
```

The `parseOne` function should return `nextName` so it can be passed as `carryName` to the next block's parse call.

### Advisor + RO code stripping

```js
var roCode = null;
parts = parts.map(function(p){
    var m = p.match(/^([A-Z][a-z]+)\s+(\[[^\]]+\])\s*(.*)/);
    if (m){ roCode = roCode || m[2]; return m[3] || ''; }
    return p;
});
// Later: if (roCode) appt.notes = 'RO: ' + roCode;
```

## When NOT to Use

- OCR quality is so poor that fields are unreadable → tell the user to get a better screenshot
- Data is truly unstructured prose (narrative text) → use an LLM
- Only one record in the screenshot → simpler single-field extraction suffices
- **Regex fatigue**: You've added 10+ patterns and still hit edge cases every session. The format is too variable — switch to a local small LLM (see Pitfall #14)

## Switching to Local LLM

When regex hits the ceiling, replace only the `parseWithRules()` step with a local model. Tesseract.js still runs in-browser; the browser sends OCR text to a local `llama-server` endpoint.

**Recommended models for text parsing (not image analysis):**

| Model | Size (Q4_K_M) | VRAM needed | 
|-------|--------------|-------------|
| Qwen2.5-1.5B-Instruct | ~1 GB | ~1.5 GB |
| Llama-3.2-3B-Instruct | ~2 GB | ~2.5 GB |
| Gemma-2-2B-Instruct | ~1.5 GB | ~2 GB |

**Quick setup (llama.cpp):**
```bash
# Install
brew install llama.cpp  # or git clone + cmake

# Launch server with GPU offload
llama-server -hf bartowski/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M -ngl 99

# Test
curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Extract as JSON: names, phones, VINs from this OCR text: ..."}]}'
```

**Prompt template for appointment parsing:**
```
Extract appointment fields from this OCR text as JSON array.
Fields: customerName, customerPhone, customerEmail, vin, vehicleInfo, serviceType, notes.
OCR text:
<raw text here>
Return only valid JSON, no explanation.
```

Keep the review modal — the LLM replaces parsing, not the whole workflow.

## Related

- `ocr-and-documents` — for extracting text from PDFs/scans (pymupdf, marker-pdf)
- `references/appointment-parser.js` — full production parser from the ShopProQuote project
- `references/parser-patterns.md` — compact extraction regex catalog with verified test cases
- `references/regex-catalog.md` — complete regex catalog with OCR corruption variants
- `llama-cpp` — local LLM setup when regex hits the ceiling (see Pitfall #14 and "Switching to Local LLM")
