# Menu & Nutrition Label OCR: Prices, Allergens, Nutrients (`bossless/menu-label-ocr`) Actor

Menu OCR and nutrition facts label reader: photos, scans and PDFs of restaurant menus, US and EU nutrition panels and food labels to structured data. One row per dish with numeric prices, currency, section and allergens; nutrients with Open Food Facts keys, re-checked by code. Pay per page.

- **URL**: https://apify.com/bossless/menu-label-ocr.md
- **Developed by:** [Alexander Daskalov](https://apify.com/bossless) (community)
- **Categories:** AI, Developer tools, E-commerce
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 page reads

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Menu & Nutrition Label OCR: Prices, Allergens, Nutrients

**Turn photos, scans and PDFs of restaurant menus, nutrition facts panels and food labels into clean structured data.** Paste links to your files (JPG, PNG, HEIC iPhone photos, PDF…, or Google Drive and Dropbox file links) and get:

- 🍝 **Menus: one row per dish** with the section, description, **every price as a number with its ISO currency** (sizes like `8,50 € / 11,00 €` become two prices), dietary marks (vegetarian, vegan, gluten-free…) and allergens resolved through the menu's own legend.
- 🥣 **Nutrition panels (US FDA and EU)**: every nutrient per 100 g and per serving, % Daily Value, serving size in grams, and the values as **Open Food Facts fields** (`fat_100g`, `salt_serving`…), ready to import.
- 🏷️ **Product labels**: brand, product name, net quantity, ingredients, the allergens printed in bold, the "may contain" allergens, best-before and use-by dates (ISO when unambiguous), lot number, manufacturer and the **barcode decoded from the bars** with its check digit verified.
- ✅ **Every number re-checked by code**: label arithmetic (energy from fat, carbs and protein, kJ vs kcal, sugars within carbohydrates, per-serving vs per-100 g, FDA calorie rounding, % Daily Values), the GS1 barcode check digit and, for PDF menus, the PDF's own text. Failed checks are reported in `checks.failed`, never silently "fixed".
- 📄 **Multi-page PDFs**: sections continue across page breaks, and a legend on the last page explains the marks on the first.

No API key, no account to connect, no setup. Mixed files are fine: each page is recognised as a menu, a nutrition panel, a product label or something else.

### What can you do with it?

- **Food delivery and ordering platforms:** onboard restaurants from a photo or PDF of their menu instead of retyping every dish and price.
- **Menu price monitoring:** compare prices across restaurants, cities or over time, with numbers and currencies you can sort and chart.
- **Nutrition and diet apps:** read nutrition facts panels into your food database with Open Food Facts keys.
- **Allergen and dietary filters:** find the vegan, gluten-free or nut-free dishes on a menu, as the menu prints them.
- **Retail and e-commerce catalogues:** fill product pages with ingredients, allergens, net quantity and the barcode from a label photo.
- **Food label compliance checks:** spot labels whose energy doesn't add up, whose sugars exceed carbohydrates or whose barcode check digit is wrong.
- **AI assistants and RAG:** give your assistant structured menus and labels instead of raw OCR text.

### What files does it accept?

| Input | Example | Supported |
|---|---|---|
| Direct image file link | `https://example.com/menus/lunch.jpg` | ✅ JPG, PNG, WebP, HEIC/HEIF (iPhone), TIFF incl. multi-page, GIF, BMP |
| Direct PDF link | `https://example.com/menus/dinner.pdf` | ✅ printed (born-digital) or scanned PDFs, page by page |
| Cloud storage file URL | a public file URL from S3 or another bucket | ✅ if it opens without logging in |
| Google Drive file | `https://drive.google.com/file/d/…/view` | ✅ shared as "Anyone with the link" |
| Dropbox file | `https://www.dropbox.com/scl/fi/…?dl=0` | ✅ |
| Restaurant websites, Google Maps places, folders | | ❌ link the photo or PDF itself |

**Options:** **What the files show** (detect per page, or menus / nutrition panels / product labels only), **Currency for unmarked prices** (an ISO code such as `USD` for menus that print `$9.99` or no sign at all; a printed €, £, ¥… always wins) and **Max pages per file** (default 10, up to 50).

### How much does it cost?

Pay per page, plus a small fee per menu row. No monthly fee.

| Event | Price (FREE/BRONZE) | Lower on paid plans |
|---|---|---|
| Page read (menu, nutrition panel or product label) | **$0.012** / page | down to $0.010 |
| Menu item (one dish or drink with its prices) | **$0.0005** / item | — |
| Actor start | $0.00005 / run | — |

Examples: a nutrition label or product label costs **$0.012**; a menu photo with 30 dishes costs **$0.027** ($0.012 + 30 × $0.0005); a 4-page PDF menu with 120 dishes costs **$0.108**.

You pay only for what you get: pages with nothing to read (say, a photo of the restaurant's front door), pages the AI couldn't read and files that can't be opened are not charged.

**Cap your spend:** set a **maximum cost per run**. Pages are sized to it before they are read; if it runs out in the middle of a menu, you get the rows it pays for and the last one is marked `"truncated": true`.

**Free plan:** the AI runs on paid infrastructure, so free-plan runs include a 3-page trial per run; upgrade to read more pages.

### How to read a menu or a nutrition label

1. Click **Try for free**. The example input reads a sample menu and a sample nutrition label.
2. Paste your own links into **Photo, scan or PDF links**, one per line.
3. Optional: set **What the files show**, a **Currency for unmarked prices**, and **Max pages per file** for long PDFs.
4. Run, then open the **Menu items** or **Labels and pages** view, or download JSON, CSV or Excel.

#### Input example

```json
{
  "fileUrls": [
    "https://example.com/menus/dinner.pdf",
    "https://example.com/labels/granola-back.jpg"
  ],
  "documentType": "auto",
  "currency": "USD",
  "maxPagesPerFile": 10
}
```

### Output

A **menu** gives one record per dish. From a 2-page Italian PDF menu:

```json
{
 "url": "https://example.com/menus/trattoria.pdf",
 "source": "direct",
 "page": 1,
 "pageCount": 2,
 "documentType": "menu",
 "recordType": "menu-item",
 "itemIndex": 5,
 "restaurantName": "Trattoria Esempio",
 "section": "Pizze",
 "name": "Margherita",
 "description": "Pomodoro, fior di latte, basilico",
 "prices": [
  {"amount": 8.5, "currency": "EUR", "text": "8,50 €"},
  {"amount": 11, "currency": "EUR", "text": "11,00 €"}
 ],
 "price": 8.5,
 "currency": "EUR",
 "priceNote": null,
 "dietary": ["vegetarian"],
 "allergens": ["gluten", "milk"],
 "allergenText": null,
 "marks": ["V", "1", "7"],
 "truncated": false,
 "checks": {"passed": ["text-layer-match"], "failed": []},
 "processedAt": "2026-10-16T00:22:40+00:00"
}
```

The allergens come from the numbers `1, 7` printed next to the dish and the menu's own legend ("1 glutine, 7 latte"); `V` is resolved the same way. A price printed as text only ("prezzo di mercato", "market price") gives `price: null` and the text in `priceNote`.

A **nutrition panel or product label** gives one record per page. From a German biscuit label (`nutrients` shortened):

```json
{
 "url": "https://example.com/labels/haferkekse.png",
 "source": "direct",
 "page": 1,
 "pageCount": 1,
 "documentType": "product",
 "recordType": "product",
 "format": "eu",
 "servingSizeText": null,
 "servingSizeG": 12.5,
 "servingsPerContainer": null,
 "nutrients": [
  {"name": "Energie", "key": "energy-kj", "unit": "kJ", "per100g": 1989, "perServing": 249, "dailyValuePercent": null},
  {"name": "Energie", "key": "energy-kcal", "unit": "kcal", "per100g": 475, "perServing": 59, "dailyValuePercent": null},
  {"name": "Fett", "key": "fat", "unit": "g", "per100g": 21, "perServing": 2.6, "dailyValuePercent": null}
 ],
 "off": {
  "energy-kj_100g": 1989, "energy-kcal_100g": 475, "fat_100g": 21, "saturated-fat_100g": 9.5,
  "carbohydrates_100g": 62, "sugars_100g": 24, "fiber_100g": 4.5, "proteins_100g": 7.2, "salt_100g": 0.55,
  "sodium_100g": 0.22, "fat_serving": 2.6, "salt_serving": 0.07
 },
 "brand": "Beispiel",
 "productName": "Haferkekse",
 "netQuantityText": "200 g",
 "netQuantity": {"value": 200, "unit": "g"},
 "ingredientsText": "Haferflocken (38 %), Weizenmehl, Zucker, Butter (Milch), Haselnüsse (8 %), Backtriebmittel: Natriumcarbonate, Salz. Kann Spuren von Soja enthalten.",
 "emphasisedAllergens": ["Haferflocken", "Weizenmehl", "Milch", "Haselnüsse"],
 "allergens": ["gluten", "milk", "nuts"],
 "allergensMayContain": ["soybeans"],
 "barcode": {"digits": "2000123456782", "type": "EAN-13", "valid": true, "from": "decoded"},
 "bestBeforeText": "31.12.2027",
 "bestBefore": "2027-12-31",
 "useByText": null,
 "useBy": null,
 "lot": "L 24318",
 "manufacturer": "Beispiel Backwaren GmbH, Musterstraße 1, 12345 Musterstadt",
 "checks": {
  "passed": ["kj-kcal-ratio", "energy-from-macros", "saturates-within-fat", "sugars-within-carbohydrates",
             "macros-within-100g", "serving-matches-100g", "barcode-check-digit"],
  "failed": []
 },
 "processedAt": "2026-10-16T02:03:31+00:00"
}
```

Every record has the same fields (those that don't apply are `null`), so menus and labels can share one spreadsheet. `recordType` is `menu-item`, `nutrition`, `product`, `other` (a page with nothing to read, not charged) or `error` (a file that couldn't be used, with the reason in `error`, not charged):

```json
{"url": "https://example.com/menus/old.pdf", "recordType": "error", "error": "file not available (HTTP 404): is the link public?"}
```

**Field notes:**

- `off` uses grams for every nutrient (sodium too) and kJ / kcal for energy, like Open Food Facts; salt and sodium fill each other (salt = sodium × 2.5).
- `nutrients[].unit` and the amounts are as printed (`mg`, `mcg`…).
- `emphasisedAllergens` lists the ingredient words whose allergen part is printed in bold or capitals, as printed.
- `allergens` uses the EU-14 names: gluten, crustaceans, eggs, fish, peanuts, soybeans, milk, nuts, celery, mustard, sesame, sulphites, lupin, molluscs.
- `barcode.from` is `decoded` when the bars were read from the image, or `read` when only the printed digits were legible (then `valid` tells you if the check digit adds up).
- Dates are converted only when they can be read one way (`31.12.2027`, `2027-12-31`); `01/02/27` stays as text.

### Which checks are run?

| Check | What it catches |
|---|---|
| `energy-from-macros` | energy that doesn't match fat, carbohydrates and protein (EU factors 9/4/4/2 within 15%, US 9/4/4 within 20%): a misread digit in any of them |
| `kj-kcal-ratio` | kJ and kcal that don't match each other |
| `saturates-within-fat`, `sugars-within-carbohydrates`, `added-within-sugars` | a part larger than its whole |
| `macros-within-100g` | more than 100 g of nutrients in 100 g |
| `serving-matches-100g` | a per-serving column that doesn't match the per-100 g column and the serving size |
| `fda-calorie-rounding`, `daily-values` | US panels: calories not rounded as the FDA requires, % Daily Values that don't match the amounts |
| `barcode-check-digit` | a barcode whose GS1 check digit is wrong |
| `text-layer-match` | PDF menus with a text layer: the dish name and its prices must appear in the PDF's own text |

### How accurate is it?

Measured on Apify with Google's Gemini 3.1 Flash-Lite model, on sample files we made with exact known answers:

- **An English menu photo** (10 dishes, 11 prices, dietary marks, allergen notes): every dish, price, section, mark and allergen right.
- **A 2-page Italian PDF menu** (19 dishes, 29 prices in `8,50 €` style, size variants, a section continuing on page 2, a numbered allergen legend): all 19 dishes and 29 prices right, every row confirmed by the PDF's text layer.
- **A US Nutrition Facts panel**, as a clean image and as a tilted, blurred iPhone HEIC photo: all 15 nutrients right on both, in repeated runs, and every % Daily Value right except that a `0%` was sometimes left empty.
- **Two EU labels** (English and German, per 100 g and per portion): all 18 values right on each; the barcode decoded and valid.
- **A label with deliberate errors** (wrong kJ, sugars above carbohydrates, a wrong barcode digit): every error flagged in `checks.failed`.

Real photos vary: use sharp, well-lit, straight-on photos, one menu page or label per photo. The checks tell you which nutrition records to look at again.

### How fast is it?

Measured on Apify at the default 1 GB: a page takes about 3 seconds for the AI to read, and pages of a PDF are read 4 at a time. The example input (a menu photo and a label) finishes in about 14 seconds including start-up; 20 single photos took about 70 seconds.

### What it doesn't do

- **No translation:** names and descriptions stay in the menu's language.
- **No guessing:** allergens and dietary tags come only from what is printed (marks, legends, "contains" statements, bold ingredients). A dish without marks gets an empty list, which does **not** mean it is allergen-free. Always confirm allergens with the restaurant or the manufacturer.
- **No web pages or Google Maps:** it reads image and PDF files, not restaurant websites or Maps listings. Link the menu photo or PDF itself.
- **No nutrition lookups:** values come from the label, not from a food database.
- **Steep or very blurry photos** may be misread: take the photo straight on.

### Use it with AI agents (MCP)

```
https://mcp.apify.com?tools=bossless/menu-label-ocr
```

Ask your assistant "what are the vegetarian dishes on this menu and what do they cost?", and it runs this actor and answers from the rows.

### Use the API from code

```bash
curl -X POST "https://api.apify.com/v2/acts/bossless~menu-label-ocr/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileUrls": ["https://example.com/menus/lunch.jpg"]}'
```

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_TOKEN")
run = client.actor("bossless/menu-label-ocr").call(run_input={
    "fileUrls": ["https://example.com/menus/dinner.pdf", "https://example.com/labels/granola.jpg"],
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if item["recordType"] == "menu-item":
        print(item["section"], "|", item["name"], "|", item["price"], item["currency"], item["allergens"])
    elif item["recordType"] in ("nutrition", "product"):
        print(item["productName"], item["off"], "failed checks:", item["checks"]["failed"])
```

### FAQ

**Do I need a Google, OpenAI or other API key?** No. Everything is included in the price.

**Which languages work?** Menus and labels in most languages and scripts are read as printed. Nutrient names are recognised in English, German, French, Italian and Spanish for the Open Food Facts keys; in other languages the nutrients are still listed with their printed names.

**Which currencies are recognised?** Printed symbols and codes such as €, £, ¥, ₹, R$, zł, лв, Kč, CHF and every common 3-letter ISO code. A bare `$` or `kr` is ambiguous, so it takes the **Currency for unmarked prices** you set, or stays empty.

**Are the allergens safe to rely on?** They are exactly what the menu or label prints, read by AI. Use them to sort and filter, and always confirm with the restaurant or the manufacturer before serving someone with an allergy.

**What happens with a private or broken link?** You get an `error` record saying why, and it isn't charged.

**Is this affiliated with Open Food Facts, the FDA or any restaurant?** No. It is not affiliated with any of them; it uses the open Open Food Facts field names so the data is easy to import.

### Credits

Barcodes are read with zxing-cpp (Apache-2.0) and PDFs are rendered with PDFium via pypdfium2 (Apache-2.0 / BSD-3-Clause).

# Actor input Schema

## `fileUrls` (type: `array`):

Links to photos, scans or PDFs of restaurant menus, nutrition facts panels or food product labels: direct files (JPG, PNG, WebP, HEIC/HEIF iPhone photos, TIFF, PDF) from your website or cloud storage, or Google Drive / Dropbox file links shared as 'Anyone with the link'. One file per line. Folders and pages that need a login don't work.

## `documentType` (type: `string`):

Detect it per page (mixed files are fine), or say what every page is: the AI then reads only that kind of page, which is a little faster.

## `currency` (type: `string`):

3-letter ISO code (EUR, USD, CAD...) used for menu prices printed without a currency or with a bare $ or kr. A printed €, £, ¥... always wins. Leave empty to keep only printed currencies.

## `maxPagesPerFile` (type: `integer`):

PDFs and multi-page TIFFs are read up to this many pages from the start. Caps the cost of an unexpectedly long file.

## Actor input object example

```json
{
  "fileUrls": [
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-menu.png",
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-nutrition-eu.png"
  ],
  "documentType": "auto",
  "maxPagesPerFile": 10
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "fileUrls": [
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-menu.png",
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-nutrition-eu.png"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("bossless/menu-label-ocr").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "fileUrls": [
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-menu.png",
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-nutrition-eu.png",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("bossless/menu-label-ocr").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "fileUrls": [
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-menu.png",
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/menu-ocr-nutrition-eu.png"
  ]
}' |
apify call bossless/menu-label-ocr --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,bossless/menu-label-ocr"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/U5eL7M38FBh0YnR45/builds/ObhgY06l5glwXlGnw/openapi.json
