Source: https://velofy.co/terbium/how-it-works/

[terbium](https://velofy.co/terbium/) / Get started

# How it works

terbium's design from first principles: what a PDF keeps and loses, how geometry rebuilds tables, how confidence gates AI calls, and where it breaks.

This page explains why terbium is built the way it is. It starts from what a document file actually contains and follows the consequences through the pipeline. The numbers on this page are constants in the 0.10.0 source, with the file named where it helps.

## First principles

### What a PDF keeps and what it loses

A Portable Document Format (PDF) file is a list of drawing instructions: draw this run of glyphs at this position in this font and size, paint this image inside this box. It keeps every word and where it sits. It does not keep the relations a reader sees: that these numbers form a table, that this cell sits under that column header, that this caption names that photo. Those relations existed in the layout tool and were flattened on export. Even the order of text inside the file is often not the reading order on the page.

So the structure has to be inferred, and the only evidence left is geometry: the x and y position of each word, its font size, whether it is bold, and the boxes of the images.

PowerPoint (PPTX), Excel (XLSX) and comma-separated values (CSV) files are different. They store structure directly: native tables, cells, rows and sheet names. Nothing needs inferring, so terbium reads that structure as it is. This is why the geometry engine runs only on PDF pages.

### The constraints

*   **The evidence is positional and slightly noisy.** PyMuPDF reports text in spans with exact boxes. terbium splits each span into words and places each word by its character offset inside the span. That is exact for fixed-width digits, such as article numbers, and approximate for proportional text.
*   **Layout matters more than domain.** A furniture lookbook and a real estate brochure share a layout; two invoices from different tools may not. See the [document taxonomy](https://velofy.co/terbium/taxonomy/).
*   **A model can read almost any layout, but every call costs money and time,** and a model can return a value the page does not contain.
*   **A wrong value is worse than a blank one.** A product information management (PIM) system or an accounting import trusts what it receives. A blank can be filled later; a wrong stock keeping unit (SKU) code spreads.

### What follows from them

1.  Rebuild the structure deterministically from geometry first. It is free, repeatable and needs no network.
2.  Measure how well that worked, table by table, and record the reasons.
3.  Spend a model call only where the measurement says it is needed, and size the model to the difficulty.
4.  When no model is configured, say what was not read instead of guessing.
5.  Keep domain knowledge in one replaceable layer, the schema, so the engine stays content-agnostic.

The rest of this page is those five rules turned into code.

## The pipeline

```
file
 -> adapt        words with boxes, images with boxes, native tables
 -> OCR          words for image-only pages (local Tesseract, if installed)
 -> classify     catalog, lookbook, transaction, resume, table, deck or unknown
 -> assemble     strip headers, split spreads, build lines, find tables and label grids
 -> score        confidence from 0.05 to 1.0 per table, with reasons
 -> escalate     hard tables to a routed model, or a plain-language message
 -> records      the schema turns tables into records
 -> output       terminal, CSV, JSON, HTML, Shopify CSV, PIM JSON
```

`terbium.parse` runs every step. `terbium.build_catalog` takes a different route for photo catalogues, described [below](https://velofy.co/terbium/how-it-works/#the-catalog-path-is-different).

## Adapt

Each format has one adapter that turns the file into a list of pages. A page holds:

*   words, each with its text, box (`x0`, `y0`, `x1`, `y1` in points), font size and a bold flag;
*   images, each with its pixel size, its box on the page and a kind;
*   native tables, when the format has them (PPTX tables, XLSX sheets, CSV files).

An image’s kind comes from its pixel size alone (`layout/images.py`). If the longer side is under 64 pixels it is an `icon`, under 300 pixels a `swatch`, and otherwise a `photo`. In catalogues, icons are usually care or certification marks, swatches are finishes, and photos are products.

Private-use glyphs (Unicode U+E000 to U+F8FF) are stripped from PDF text. Catalogues use them to draw icons with a font, and they would otherwise appear as noise inside words. Per-format details are on [File formats](https://velofy.co/terbium/formats/).

## Optical character recognition (OCR)

A flattened export (a PDF saved from a design tool as images) has no text layer. When Tesseract is installed, `parse` and `build_catalog` render each PDF page that has images and fewer than 3 words at 300 dots per inch (DPI), run Tesseract on it through PyMuPDF, and put the recognized words into the same word shape. Every later step treats them like normal words. Without a `tesseract` binary on `PATH`, this step does nothing.

## Classify

Each page gets a score for each document type from simple signals: keywords, money amounts and dates, invoice numbers, `Label: value` lines, email addresses and phone numbers, bold or large headings, photo count and image area, SKU-like codes, native tables, and whether the file is a PPTX. The page scores are summed across the document.

*   The highest total wins. If it is under 0.5, the type is `unknown`.
*   A PPTX file is a `deck` unless its transaction score is higher than its deck score.
*   Between `catalog` and `lookbook`, the higher score wins, and a tie goes to `catalog`.

The type picks the default schema and, on the command line, the view. It is a guess from keywords and counts, so `--type` and `doc_type=` override it. Details: [Invoices, receipts and resumes](https://velofy.co/terbium/documents/).

## Assemble: rebuild the structure

These steps run on PDF pages that have words and no native tables.

### Lines

Words whose vertical centres lie within 3 points of a line’s running average join that line (`layout/lines.py`). Lines are sorted top to bottom and words left to right. A line is the basic unit for everything that follows.

### Running headers and footers

Each page is cut into 12 horizontal bands. A line in the top two or bottom two bands is a running header or footer when its text, with standalone numbers removed, is 48 characters or shorter and recurs in the same band at least 3 times, and at least once for every five pages in longer documents. A bare page number in those bands is always stripped. Text that repeats in the middle of the page, such as an axis label like “length x width x height”, is kept on purpose, because table detection needs it (`layout/dehead.py`).

### Two-page spreads

A page more than 1.7 times as wide as it is tall may be two printed pages side by side. If at most 4 words, or 6% of the words if that is more, sit in the band from 46% to 54% of the width, the page is cut at the middle. Otherwise content crosses the centre, so the page stays whole (`layout/columns.py`). A Letter or A4 landscape page is never split. A 16:9 slide saved as a PDF is wide enough to be checked.

### Cells

Inside a line, a horizontal gap wider than 24 points separates two cells. So four product names on one baseline become four cells, not one long run of text.

### Tables

The content-agnostic detector (`layout/tables.py`) works like this:

1.  Consecutive lines with two or more cells form a block. A line with a single cell, such as a title or a paragraph, ends the block, and so does a vertical gap larger than 26 points or 2.4 times the median line gap, whichever is larger.
2.  A block needs at least two lines.
3.  The column count is the most common number of cells per line. Column positions come from the horizontal centres of all cells, split at the largest gaps.
4.  The first line becomes the header row. Each other cell goes to the column whose range contains its centre.
5.  If the median cell holds more than 5 words, the block is prose laid out in columns, and it is rejected.
6.  The title is the nearest line within 70 points above the table, if it has 8 words or fewer.

On a page with three or more images, a table in which fewer than 15% of the cells contain a digit is dropped. It is a grid of product names, and label extraction reads it instead.

The `furniture` schema swaps in a different detector (`layout/grid.py`) that classifies each line as a title, an axis label, a data row (a dimension plus article numbers) or an attribute, and builds size by finish matrices from that.

### Label grids

A lookbook page has photos with a name under each and no table. On a page that produced no table and has a photo, or at least two images, terbium collects short lines (8 words or fewer, 48 characters or fewer, with no dimension, code or composition), joins a name that wraps onto a second or third line, and takes a label in a noticeably larger font (at least 1.18 times the page’s median size) as the collection title. The names must sit in at least two column positions, unless the page has six or more photos. These records get a fixed confidence of 0.82 (`layout/labels.py`).

### Image-only pages

A page with images, fewer than 5 words and no table or label grid cannot be read from geometry. terbium lists it rather than returning nothing.

## Score

Every table starts at 0.98 and loses points for missing column headers, a missing title, empty cells and rows that do not line up with the columns. The result is clamped to 0.05 to 1.0 and copied to every record from that table, with the reasons. See [Confidence and escalation](https://velofy.co/terbium/confidence/).

The score measures the shape of a table, not the truth of its values. A neat table with a value in the wrong column can still score high. A ragged one with correct values scores low.

## Escalate

A table under the threshold (0.72 by default) is hard.

*   **With a provider key**, each hard table goes to an artificial intelligence (AI) model, at a tier chosen by a difficulty score that combines uncertainty, size and width. The model gets the page’s raw text, terbium’s best attempt at the table and, for PDF pages, an image of the page (unless `enable_vision=False`). It returns the table as JavaScript Object Notation (JSON). terbium rebuilds the table from that, sets its confidence to 0.92 and marks its records `origin="ai"`.
*   **Without a key**, terbium attaches a message naming the pages, up to three reasons and a recommended tier, and prints it to stderr.

Easy tables never reach a model. A 200-page catalogue with five hard tables costs five arrange calls, not one per page. See [AI lane and routing](https://velofy.co/terbium/ai-and-routing/).

## Records

The schema turns each table into records (`schema/`). `product` maps column headers to fields by meaning, `generic` keeps the headers as they are, `furniture` makes one record per article number, and `transaction` and `resume` read forms and sections. Label grids skip the schema: each name becomes a record with a `name` field and, when the page has a collection title, a `collection` field.

Invoices and resumes are not mostly tables. When the table records (plus, for an invoice, a summary built from the page’s labels) number fewer than two, the transaction schema builds records from `Label: value` pairs and from description-and-amount lines, and the resume schema builds them from section headings. See [Schemas](https://velofy.co/terbium/schemas/).

## The catalog path is different

In a catalogue, the one thing every product has is a photo. Codes, names and materials may or may not be printed, and if they are, they sit near the photo. So `build_catalog` anchors each row on a photo instead of a table:

1.  Extract the photos, skipping icons, banners and logos that repeat across pages.
2.  Collect the text near each photo: below it first, then to its right, then above it.
3.  Name the row from the caption label, else the page’s largest short heading, else a short name-like line near the photo.
4.  Find a SKU, materials or ingredients, and dimensions in that text with strict patterns.

When a PDF or PPTX has no usable photos, or `images_dir` is not set, rows come from the product table instead. See [The catalog table](https://velofy.co/terbium/catalog/).

## Trade-offs

*   **Fixed rules, tuned on catalogues.** The 24-point cell gap, the 70-point title distance and the other constants suit typical catalogue typography. Unusual layouts can defeat them. Confidence catches some of these cases, not all.
*   **Shape-based confidence.** Penalties detect missing headers, missing titles, empty cells and ragged rows. They do not detect a value placed in the wrong column of an otherwise neat table.
*   **Keyword classification.** It is cheap and easy to explain, and it can be wrong. Prose that mentions “experience” or “invoice” can be classified as a resume or a transaction.
*   **What AI may change.** The catalog fill and the transaction fill only fill blanks. The arrange step replaces a hard table’s structure with the model’s version. Product enrichment overwrites `sku`, `name`, `category`, `dimensions`, `color` and `material` when the model returns them.
*   **Model identifiers (IDs) are fixed in the source.** Each provider maps the three tiers to model IDs in code. The changelog notes that they are not verified against the provider application programming interfaces (APIs).
*   **Records, not text.** terbium does not produce Markdown for retrieval pipelines.

## Failure modes

*   **A table with no header row.** The first data row becomes the column headers, so that row is lost as data and the fields are named after its values.
*   **Estimated word positions.** A word’s position inside a span is estimated from its character offset, which is exact only when every character has the same width. In a span that mixes wide and narrow characters, the estimate can be off, and a cell can land in the neighbouring column. When that leaves rows ragged, the score drops.
*   **A scanned PDF without Tesseract.** No words, so no tables. `parse` reports the image-only pages.
*   **An image file without Tesseract.** Zero records and no message. See [Troubleshooting and limits](https://velofy.co/terbium/troubleshooting/).
*   **A wrong document type.** The wrong schema runs. Pass `--type` or `doc_type=`.
*   **A provider key without its software development kit (SDK).** Model calls fail inside `parse`, the failure is swallowed, and the hard tables stay as they were.

Each of these, with what to do about it, is on [Troubleshooting and limits](https://velofy.co/terbium/troubleshooting/).
