Source: https://velofy.co/terbium/examples/

[terbium](https://velofy.co/terbium/) / Guides

# Worked examples

End-to-end terbium recipes: a supplier catalogue to CSV and Shopify, a folder of invoices to one CSV, a price list to feeds, triage, and a review queue.

Each example below is complete and runs on terbium 0.10.0. They were run against small generated files on 25 October 2026, and the outputs shown are from those runs. Your numbers will differ with real documents.

## First principles

Every task here is the same three moves: get records out, decide which ones you trust, and write them where they need to go.

*   **Records out.** `build_catalog` gives one dict per product photo. `parse` gives `Record` objects with a confidence each. Pick by the document, not by habit: photos anchor catalogue rows, tables and forms anchor everything else.
*   **Trust.** Nothing downstream should import a row blindly. Use each record’s `confidence`, `doc.ambiguous_records`, and simple checks such as blank or repeated stock keeping unit (SKU) codes to build a review list.
*   **Write.** The catalog comma-separated values (CSV), Shopify and product information management (PIM) writers return the text and also write a file when you pass a path. `records_to_csv` and `render_html` return text for you to save.

## A supplier catalogue to a CSV, photos and a review list

Build the table, write it, and list the rows a person should check before import: rows with no name, no SKU, or a SKU that appears on more than one product.

```
from collections import Counter

import terbium

rows = terbium.build_catalog("supplier.pdf", images_dir="supplier_images/")
terbium.to_catalog_csv(rows, "supplier.csv")

counts = Counter(r["sku"] for r in rows if r["sku"])
review = [
    r for r in rows
    if not r["name"] or not r["sku"] or counts[r["sku"]] > 1
]
print(f"{len(rows)} products, {len(review)} to review")
for r in review:
    print(r["page"], r["image"], r["name"], r["sku"])
```

On a two-product test page where only the first product printed a code, both rows came back with that code, and the check caught it:

```
2 products, 2 to review
1 Anatolia_Kilim.png Anatolia Kilim RUG-101
1 Nordic_Shag.png Nordic Shag RUG-101
```

Why that happens is on [Troubleshooting and limits](https://velofy.co/terbium/troubleshooting/#two-products-share-one-sku).

## A catalogue to a Shopify import

`build_catalog` returns plain dicts, and the feed writers take `Record` objects. Wrap each row, and let `enrich_fields` add the colour and material families that become Shopify tags:

```
import terbium
from terbium.normalize import enrich_fields

rows = terbium.build_catalog("supplier.pdf", images_dir="supplier_images/", announce=False)
records = [
    terbium.Record(
        sku=r["sku"],
        fields=enrich_fields({
            "name": r["name"],
            "material": r["materials"],
            "dimensions": r["dimensions"],
            "image": r["image"],
        }),
        source_page=r["page"] - 1,   # Record pages are 0-based, catalog rows 1-based
        confidence=1.0,              # catalog rows carry no score; choose your own
    )
    for r in rows
]
terbium.to_shopify_csv(records, "shopify.csv")
```

`Image Src` is filled from the `image` field as it is, which here is a local file name. Shopify imports images from web addresses (URLs), so upload the photos first and put each URL in `image`.

## A folder of invoices to one CSV of line items

```
from pathlib import Path

import terbium

line_items = []
for pdf in sorted(Path("invoices").glob("*.pdf")):
    doc = terbium.parse(str(pdf), doc_type="transaction", announce=False)
    if doc.escalation:
        print(pdf.name, doc.escalation)
    for r in doc.records:
        if r.get("record_type") == "line_item":
            line_items.append({"file": pdf.name, **r.to_dict()})

with open("line_items.csv", "w", newline="", encoding="utf-8") as fh:
    fh.write(terbium.records_to_csv(line_items))
```

Each line item carries the invoice’s header fields (`number`, `date`, `customer` and so on), so one flat CSV holds everything. The output, trimmed at the right:

```
file,sku,description,quantity,unit_price,amount,record_type,number,date,customer,total,...
inv0.pdf,,Widget A,10,80.00,800.00,line_item,INV-2041,2026-07-20,Acme Corp,800.00,...
inv1.pdf,,Widget B,2,15.00,30.00,line_item,INV-2042,2026-07-20,Acme Corp,30.00,...
```

The `total` column is the invoice total copied into each line, not a per-line value. `doc_type="transaction"` skips classification; drop it if the folder mixes document kinds.

The same from the shell, one CSV per invoice:

```
for f in invoices/*.pdf; do
  terbium "$f" --type transaction --csv "${f%.pdf}.csv" --limit 0
done
```

## A price list to Shopify and PIM JSON

A spreadsheet is classified as `table` and read with the `product` schema, which maps the column headers to product fields by meaning:

```
import terbium

doc = terbium.parse("pricelist.xlsx")
print(doc.doc_type, doc.stats)            # table Stats(total=2, confident=2, ambiguous=0)
terbium.to_shopify_csv(doc.records, "shopify.csv")
terbium.to_pim_json(doc.records, "products.json")
```

For a sheet with the headers `SKU`, `Product name`, `Colour`, `Material`, `Price`, `Dimensions` and `Qty`, the first JavaScript Object Notation (JSON) object was:

```
{
  "sku": "AB-100",
  "confidence": 0.98,
  "name": "Oak chair",
  "color": "Off White",
  "material": "solid oak",
  "price": "$120.00",
  "price_amount": 120.0,
  "currency": "USD",
  "dimensions": "45 x 50 x 80 cm",
  "quantity": "4",
  "collection": "Spring 2026",
  "color_family": "white",
  "material_family": "wood",
  "dimensions_mm": [450.0, 500.0, 800.0]
}
```

`collection` is the sheet name. The JSON file itself is indented one value per line; the list is shown on one line here.

## Triage a folder before parsing

`parse` classifies as its first step, but you can classify on its own when you only need to sort files. It adapts the file and scores it, without rebuilding tables:

```
import terbium
from terbium.documents import get_adapter

for path in ["supplier.pdf", "invoices/inv0.pdf", "pricelist.xlsx"]:
    pages = get_adapter(path).parse(path)
    doc_type, scores = terbium.classify(pages)
    print(f"{path}: {doc_type}")
```

```
supplier.pdf: catalog
invoices/inv0.pdf: transaction
pricelist.xlsx: table
```

Then send `catalog` and `lookbook` files to `build_catalog`, and the rest to `parse`. Image files and scanned pages need Tesseract installed before they have words to classify.

## A stricter threshold and AI only on the weak records

Raise the threshold to see more records as ambiguous, then spend artificial intelligence (AI) model calls on those only:

```
import terbium

doc = terbium.parse("pricelist.xlsx", threshold=0.9, announce=False)
print(len(doc.ambiguous_records), "of", doc.stats.total, "records below 0.9")

ai = terbium.AI()
if ai.available:
    records = terbium.enrich_products(doc.records, ai, only_below=0.9)
```

`enrich_products` returns a new list. Records at or above `only_below` pass through untouched, and so does every record when no provider is available. Pass a `terbium.AI` object, not `True`.

## The image manifest as a CSV

`export_images` returns the manifest; the command line does not write one. Save it with the `csv` module:

```
import csv

import terbium

manifest = terbium.export_images("supplier.pdf", "photos/")
if manifest:
    with open("photos/manifest.csv", "w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=list(manifest[0]))
        writer.writeheader()
        writer.writerows(manifest)
```

```
page,product,collection,file,format,width_px,height_px,colorspace,bytes,dpi,dominant_color,bbox
```

`bbox` is written as a tuple such as `"(43.3, 40.0, 336.7, 260.0)"`. The fields are described on [Pulling out images](https://velofy.co/terbium/images/).
