Skip to content
Velofyterbium

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.

On this page
  1. First principles
  2. A supplier catalogue to a CSV, photos and a review list
  3. A catalogue to a Shopify import
  4. A folder of invoices to one CSV of line items
  5. A price list to Shopify and PIM JSON
  6. Triage a folder before parsing
  7. A stricter threshold and AI only on the weak records
  8. The image manifest as a CSV

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.

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.