Walk through any tourist area in Bali, Yogyakarta, Lombok or Labuan Bajo and you’ll see the same thing: a small warung or café with good food and a menu that only makes sense in Indonesian. Foreign guests point at photos, open Google Lens, or order the one dish they recognise. The owner knows it costs them sales but doesn’t have time to fix it, and the menu changes too often for a printed English version to stay correct.
That’s a small, concrete service you can sell: a mobile web menu in Indonesian plus two or three other languages, reached by a QR code on the table, which you update when prices change. AI does the boring part: reading the paper menu and drafting translations. You do the parts owners pay for: checking every price, fixing dish names, and keeping it up to date.
In this guide you’ll build one Python script, menu.py, that:
- reads photos of a paper menu with Gemini and turns them into a CSV of sections, items and prices
- checks every price the AI read against the digits it says were printed, and flags anything odd
- translates names and descriptions into English, Chinese and Korean (or whatever you choose), without letting the AI invent ingredients
- builds a single-file, fast mobile menu page with a language switcher
- generates a print-ready QR code
You then deploy the page free on Cloudflare Pages. AI cost per menu: under $0.05. Build time for the script: about an hour. Time per client after that: 1–2 hours for the first version, 10–20 minutes per update.
What you need
| Item | Cost | Notes |
|---|---|---|
| Python 3.10+ | $0 | Windows, macOS or Linux |
| Gemini API key | $0 to start | aistudio.google.com → “Get API key” |
google-genai, pydantic, pillow, qrcode | $0 | pip install |
| Node.js 22+ | $0 | Only to run Cloudflare’s wrangler deploy tool |
| Cloudflare account | $0 | Pages free plan hosts static sites |
| Domain (optional) | ~$10–11/year for .com | Or use the free *.pages.dev address |
| Clear photos of the menu | $0 | Flat, well lit, one page per photo |
Pricing as of October 2026 (check ai.google.dev/pricing and Cloudflare’s Pages docs before you rely on it; both change):
- Gemini 3.5 Flash-Lite (
gemini-3.5-flash-lite, paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens. Thinking tokens are billed as output. This is the script’s default. - Gemini free tier: rate-limited, fine for testing on your own menu. Use a paid-tier key for client work (see Reality check).
- Cloudflare Pages free plan: unlimited static requests and bandwidth, 500 builds per month, and up to 100 Pages projects per account. A menu page is one HTML file of a few dozen KB, so traffic is never the issue; the 100-project cap only matters if you host 100+ clients in one account (another reason to put each project in the client’s own account).
How the cost works out
Reading the menu. On Gemini 3.x an image costs a fixed number of input tokens set by the media resolution, 1,120 tokens at the default, regardless of pixel size. A typical warung menu is 2–4 photos. With about 400 tokens of prompt per photo, four photos come to roughly 6,000 input tokens. A 60-item menu returns about 3,000–4,000 output tokens of JSON including minimal thinking.
- Input: 6,000 × $0.30/1M = $0.0018
- Output: 4,000 × $2.50/1M = $0.01
Translating. Three languages × 60 items is roughly 9,000 input and 12,000 output tokens: about $0.03.
So a full menu costs roughly $0.04–0.05 in AI, well under Rp 1,000. The script prints the real token count and cost of every run, so you don’t have to trust these estimates.
Step 1: Set up the project
mkdir qrmenu && cd qrmenu
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install google-genai pydantic pillow "qrcode[pil]"
mkdir photos
export GEMINI_API_KEY="paste-your-key-here" # Windows PowerShell: $env:GEMINI_API_KEY="..."
Put the menu photos in photos/, named in page order (01.jpg, 02.jpg, …). Photo quality matters more than anything else in this pipeline. Shoot straight on, in daylight, with no glare from laminated pages. If a page has very small print, take two photos (top half and bottom half) instead of one.
Step 2: Why the price check matters
The most expensive mistake this service can make is a wrong price. If the menu says Rp 35.000 and your page says Rp 25.000, the owner loses money on every order or argues with a tourist at the till, and you lose the client.
So the script asks Gemini for two things per item: price_text, the price exactly as printed (35K, 35.000, Rp35rb), and price_idr, the integer it thinks that means (35000). Then plain Python, not AI, compares them. If the digits in price_text don’t match price_idr (allowing the common “35 means 35,000” shorthand), or a price is below Rp 1,000 or above Rp 2,000,000, the row gets flagged. You still check every price against the paper menu by hand. The flags tell you where to look first.
The same idea applies to translation. If the paper menu has no description for an item, the script throws away any description the AI writes for it. That stops the model “helpfully” adding “fragrant rice with chicken and peanuts” to a dish that has no peanuts.
Step 3: The script
Save this as menu.py:
import argparse, csv, datetime, html, json, os, re, sys, time
from io import BytesIO
from pathlib import Path
import qrcode
from google import genai
from google.genai import errors, types
from PIL import Image, ImageOps
from pydantic import BaseModel
MODEL = os.environ.get("MENU_MODEL", "gemini-3.5-flash-lite")
PRICE_IN, PRICE_OUT = 0.30, 2.50 # USD per 1M tokens, Gemini 3.5 Flash-Lite paid tier
LANGS = {"en": "English", "zh": "Simplified Chinese", "ko": "Korean"}
LABELS = {"id": "Indonesia", "en": "English", "zh": "中文", "ko": "한국어", "ja": "日本語"}
MENU, TRANS = "menu.csv", "translations.csv"
BATCH = 40
class Item(BaseModel):
section: str
name: str
description: str
price_text: str
price_idr: int
class Translation(BaseModel):
id: int
section: str
name: str
description: str
EXTRACT_PROMPT = """This is a photo of one page of a restaurant menu in Indonesia.
List every item with a price, in the order printed.
- section: the heading the item is under, as printed (e.g. "Makanan", "Minuman"). Empty if none.
- name and description: copy exactly as printed. Do not translate, correct or add anything.
If no description is printed, use an empty string.
- price_text: the price exactly as printed, e.g. "35K", "Rp 35.000", "35".
- price_idr: that price as a whole number of rupiah, e.g. 35000.
- If an item has several sizes or prices, return one item per price and put the size in the name.
Skip anything you cannot read clearly rather than guessing."""
TRANSLATE_PROMPT = """Translate these Indonesian restaurant menu entries into {lang} for tourists.
Rules:
- Return exactly one entry per input id, with the same id.
- name: a short, natural {lang} name. Keep well-known Indonesian dish names (Nasi Goreng,
Sate, Rendang, Gado-Gado, Es Teh) in Latin letters and add a short meaning, e.g.
"Nasi Goreng (Fried Rice)". For non-Latin languages, give the {lang} name followed by
the original in brackets.
- description: translate only what is written. Never add ingredients, allergens, spice
level, portion size or praise. If the input description is empty, return an empty string.
- section: translate the section heading.
- Follow the glossary exactly.
Glossary (Indonesian = {lang}):
{glossary}
Entries:
{items}"""
client = genai.Client() # reads GEMINI_API_KEY
usage = {"in": 0, "out": 0}
def call(contents, schema):
for attempt in range(3):
try:
resp = client.models.generate_content(
model=MODEL,
contents=contents,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=schema,
thinking_config=types.ThinkingConfig(thinking_level="minimal"),
),
)
except errors.APIError as e:
print(f" API error {e.code} (attempt {attempt + 1}), retrying", file=sys.stderr)
time.sleep(5 * (attempt + 1))
continue
u = resp.usage_metadata
usage["in"] += u.prompt_token_count or 0
usage["out"] += (u.candidates_token_count or 0) + (u.thoughts_token_count or 0)
if resp.parsed is not None:
return resp.parsed
sys.exit("Request failed 3 times.")
def image_part(path):
img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
img.thumbnail((2048, 2048))
buf = BytesIO()
img.save(buf, "JPEG", quality=90)
return types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
def price_flags(text, idr):
digits = re.sub(r"\D", "", text)
flags = []
if not digits:
flags.append("no price digits")
elif str(idr) != digits and str(idr) != digits + "000":
flags.append(f"price mismatch: '{text}' vs {idr}")
if not 1000 <= idr <= 2_000_000:
flags.append("price out of range")
return flags
def read_csv(path):
with open(path, newline="", encoding="utf-8-sig") as f: # -sig: Excel adds a BOM
return list(csv.DictReader(f))
def write_csv(path, rows, fields):
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(rows)
def extract(args):
exts = {".jpg", ".jpeg", ".png", ".webp"}
photos = sorted(p for p in Path(args.folder).iterdir() if p.suffix.lower() in exts)
rows, seen = [], set()
for photo in photos:
print(f"Reading {photo.name}")
for it in call([image_part(photo), EXTRACT_PROMPT], list[Item]):
flags = price_flags(it.price_text, it.price_idr)
key = it.name.strip().lower()
if key in seen:
flags.append("duplicate name")
seen.add(key)
rows.append({"id": len(rows) + 1, "section": it.section.strip(),
"name": it.name.strip(), "description": it.description.strip(),
"price_text": it.price_text, "price_idr": it.price_idr,
"photo": photo.name, "flags": "; ".join(flags)})
if not rows:
sys.exit("No items found. Check the photos.")
write_csv(MENU, rows, list(rows[0]))
flagged = sum(1 for r in rows if r["flags"])
print(f"{len(rows)} items, {flagged} flagged. Check EVERY price against the paper menu.")
def translate(args):
items = read_csv(MENU)
glossary = Path(args.glossary).read_text(encoding="utf-8") if args.glossary else "(none)"
rows = []
for code, lang in LANGS.items():
print(f"Translating to {lang}")
for i in range(0, len(items), BATCH):
batch = [{"id": int(r["id"]), "section": r["section"], "name": r["name"],
"description": r["description"]} for r in items[i:i + BATCH]]
ids = {b["id"] for b in batch}
prompt = TRANSLATE_PROMPT.format(lang=lang, glossary=glossary,
items=json.dumps(batch, ensure_ascii=False))
for attempt in range(3):
got = {t.id: t for t in call(prompt, list[Translation])}
if set(got) == ids:
break
print(f" ids mismatch (attempt {attempt + 1}), retrying", file=sys.stderr)
else:
sys.exit(f"Batch starting at item {batch[0]['id']} failed. Lower BATCH.")
for b in batch:
t = got[b["id"]]
rows.append({"id": b["id"], "lang": code, "section": t.section.strip(),
"name": t.name.strip(),
# no source description = no translated description
"description": t.description.strip() if b["description"] else ""})
write_csv(TRANS, rows, ["id", "lang", "section", "name", "description"])
print(f"Wrote {TRANS}. Review it before you build.")
def span(code, text):
return f'<span class="l-{code}">{html.escape(text)}</span>' if text else ""
def build(args):
items = read_csv(MENU)
trans = {(r["id"], r["lang"]): r for r in read_csv(TRANS)} if Path(TRANS).exists() else {}
codes = ["id"] + list(LANGS)
body, section = [], None
for r in items:
if r["section"] != section:
section = r["section"]
heads = span("id", section) + "".join(
span(c, trans.get((r["id"], c), {}).get("section", "")) for c in LANGS)
body.append(f"<h2>{heads}</h2>")
names = "".join(span(c, trans.get((r["id"], c), {}).get("name", "")) for c in LANGS)
descs = span("id", r["description"]) + "".join(
span(c, trans.get((r["id"], c), {}).get("description", "")) for c in LANGS)
price = f"Rp {int(r['price_idr']):,}".replace(",", ".")
body.append(f'<div class="item"><div><b>{html.escape(r["name"])}</b>'
f'<i>{names}</i><p>{descs}</p></div><span class="p">{price}</span></div>')
hide = "".join(f'body:not([data-lang="{c}"]) .l-{c}{{display:none}}' for c in codes)
buttons = "".join(f'<button onclick="setLang(\'{c}\')">{LABELS.get(c, c)}</button>'
for c in codes)
today = datetime.date.today().isoformat()
page = f"""<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(args.name)} - Menu</title><style>
body{{font-family:system-ui,sans-serif;max-width:640px;margin:auto;padding:12px;color:#222}}
nav{{position:sticky;top:0;background:#fff;padding:8px 0;display:flex;gap:6px;flex-wrap:wrap}}
button{{padding:8px 12px;border:1px solid #ccc;border-radius:16px;background:#fafafa}}
h2{{margin-top:28px;border-bottom:2px solid #eee}}
.item{{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid #f0f0f0}}
.item i{{display:block;color:#555;font-style:normal}} .item p{{margin:4px 0 0;color:#777;font-size:.9em}}
.p{{white-space:nowrap;font-weight:600}} footer{{margin:24px 0;color:#777;font-size:.85em}}
{hide}</style></head><body data-lang="id">
<h1>{html.escape(args.name)}</h1><nav>{buttons}</nav>
{"".join(body)}
<footer>{html.escape(args.note)}<br>Updated {today}</footer>
<script>
const langs={json.dumps(codes)};
function setLang(l){{document.body.dataset.lang=l;localStorage.setItem("lang",l)}}
const s=localStorage.getItem("lang")||navigator.language.slice(0,2);
setLang(langs.includes(s)?s:(langs.includes("en")?"en":"id"));
</script></body></html>"""
Path("site").mkdir(exist_ok=True)
Path("site/index.html").write_text(page, encoding="utf-8")
print("Wrote site/index.html")
if args.url:
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=20, border=4)
qr.add_data(args.url)
qr.make_image().save("qr.png")
print(f"Wrote qr.png -> {args.url}")
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
e = sub.add_parser("extract"); e.add_argument("folder")
t = sub.add_parser("translate"); t.add_argument("--glossary")
b = sub.add_parser("build"); b.add_argument("--name", required=True)
b.add_argument("--url"); b.add_argument("--note", default="Prices in IDR.")
args = ap.parse_args()
{"extract": extract, "translate": translate, "build": build}[args.cmd](args)
if usage["in"]:
cost = usage["in"] * PRICE_IN / 1e6 + usage["out"] * PRICE_OUT / 1e6
print(f"Tokens: {usage['in']:,} in, {usage['out']:,} out. Cost: ${cost:.4f}")
if __name__ == "__main__":
main()
A few design choices worth knowing:
- Prices never go through translation. They live only in
menu.csv, which is read by the build step. A price change never needs an AI call. - Translations are keyed by item
id. If you add items later, give them new ids at the bottom ofmenu.csvand re-runtranslate. - The original Indonesian name is always shown. Guests can point at it, and staff can read it. The translated name appears underneath.
- The page is one HTML file with no external scripts, fonts or trackers. It loads quickly on a weak 4G signal in a beach café, and there’s nothing to break.
Step 4: Read the menu
python menu.py extract photos/
Example output:
Reading 01.jpg
Reading 02.jpg
Reading 03.jpg
54 items, 3 flagged. Check EVERY price against the paper menu.
Tokens: 4,912 in, 3,388 out. Cost: $0.0099
Open menu.csv in Excel, LibreOffice or Google Sheets. Sort by flags to see problems first, then go through every row with the paper menu next to you. Fix typos in names, correct prices, delete junk rows, and clear the flags column when you’re done. Save it as CSV (UTF-8). Excel on Windows with an Indonesian regional setting saves CSV with semicolons instead of commas, which the script can’t read; if that happens, use Google Sheets or LibreOffice (export with comma separator) instead.
This is the slow part, roughly 20–30 minutes for a 50–60 item menu. Don’t skip it. It’s the part you’re being paid for.
Step 5: Translate
Make a glossary.txt for anything that must be said a certain way. Owners usually have opinions about their signature dishes:
Nasi Campur Bu Sri = Bu Sri's Mixed Rice Plate
Sambal Matah = Sambal Matah (raw Balinese shallot chili relish)
Es Kopi Susu Gula Aren = Iced Coffee with Milk and Palm Sugar
Then:
python menu.py translate --glossary glossary.txt
Open translations.csv and review it. Check the language you can read properly. For languages you can’t read, see the Reality check below before you put them live.
To change languages, edit the LANGS dictionary at the top of the script, for example {"en": "English", "ja": "Japanese"}. Pick languages based on who actually visits that café. The owner knows this better than any statistic.
Step 6: Build, deploy and print the QR code
Choose the project name first, because it becomes the URL. Then build:
python menu.py build --name "Warung Bu Sri" \
--url https://warung-bu-sri.pages.dev \
--note "Prices in IDR, excluding 10% tax and 5% service."
Ask the owner how tax and service are charged and put it in --note. Unexpected extra charges on the bill are a common complaint from tourists, and a clear note prevents it.
Open site/index.html in your browser and test every language button on your phone. Then deploy with Cloudflare’s wrangler CLI:
npx wrangler login
npx wrangler pages project create warung-bu-sri --production-branch main
npx wrangler pages deploy site --project-name warung-bu-sri --branch main
The menu is now live at https://warung-bu-sri.pages.dev. If that name is taken, Cloudflare gives you a different subdomain. In that case, use the URL it prints and re-run build with the correct --url so the QR code matches. If the owner has a domain, connect it in the Cloudflare dashboard under the Pages project → Custom domains.
qr.png is a 20-pixel-per-module PNG, sharp enough for A6 table tents or stickers. Before you print 20 of them, scan the printed proof with two different phones (one iPhone, one Android) from table distance.
Updating later takes minutes: edit menu.csv, run translate only if names or descriptions changed, run build, run the deploy command again. The URL doesn’t change, so the printed QR codes keep working. That’s the main selling point compared with reprinting a paper menu.
Cost breakdown
| Item | One-off | Monthly |
|---|---|---|
| Gemini API (extract + 3 languages) | ~$0.04 per menu | ~$0.01 per update |
| Cloudflare Pages hosting | $0 | $0 |
*.pages.dev address | $0 | $0 |
Custom .com domain (optional) | ~$10–11/year | — |
| QR printing (table tents/stickers) | Get a quote from a local print shop | — |
| Your time | 1–2 hours first version | 10–20 min per update |
Turning it into income
There is no standard market rate for this, so here’s a way to think about pricing rather than a promise. The AI and hosting cost is close to zero, so your price is really for your time, your accuracy, and the fact that the owner doesn’t have to think about it.
A reasonable starting structure for small cafés in tourist areas:
- Setup: a one-off fee covering extraction, review, two or three languages, deployment and a printed QR proof. Charge for a couple of hours of skilled work in your local market. As an illustration only, something like Rp 300,000–750,000 is a plausible place to test, then adjust based on how owners react. That number is a starting guess, not a market benchmark.
- Updates: either a small monthly fee for “unlimited reasonable updates”, or a flat fee per update. Monthly works better for places that change prices or specials often.
- Extras: a custom domain, the menu photos the owner already has added to the page, or Google Business Profile menu updates done at the same time.
How to find clients without being a nuisance:
- Build one for free first, for a café you like, with the owner’s permission. Ask whether you can use it as an example.
- Walk in with your phone showing a live demo, ideally their own menu. A working page with their own dishes on it is far more convincing than a pitch.
- Target places with visible foreign customers and an Indonesian-only or badly translated menu. Google Maps reviews that mention “menu only in Indonesian” or “confusing prices” are a good sign.
- Don’t pitch busy owners at lunch. Mid-afternoon is quieter.
Realistic expectations: some owners will say no, some will say “my nephew can do that,” and some already use a POS system with a built-in QR menu. Expect more no’s than yes’s, especially at first. Even with a handful of clients on a monthly update plan, this is a modest side income that needs ongoing work, not a business that runs itself.
Reality check
Wrong prices are your fault. The AI misreads blurry photos, confuses 8 and 6, and sometimes reads a crossed-out old price. The price check catches mismatches between what the AI says it read and what it reported. It can’t catch a misread that’s consistent with itself. Check every price by hand, and have the owner sign off on the final page before it goes on the tables.
Never invent allergen or dietary information. The script deliberately won’t add descriptions that aren’t on the paper menu. Don’t add “vegan”, “halal”, “gluten-free” or “contains nuts” unless the owner tells you in writing. A wrong allergen label can put someone in hospital.
Don’t sell languages nobody can check. English you can probably review yourself. Chinese or Korean output from the script is usually understandable, but you won’t spot when it’s wrong. Either find a native speaker to review it (a friend, a guest, a paid proofreader) or label those languages as machine-translated in the footer. Being honest about this is better than a confidently wrong menu.
POS systems already do QR menus. Many cafés already pay for a POS system that includes QR ordering. If the owner already has one and is happy with it, your pitch is the translation and the updates, not the QR code. If their system can import translated text, sell the translation work instead of a separate page.
Bad photos give bad results. Glare on laminated menus, dark photos and menus written on chalkboards cause most extraction errors. Retake the photo before you spend time fixing a bad CSV.
Free tier and client data. On Google’s free tier, inputs may be used to improve Google’s products. A menu isn’t secret, but use a paid-tier key for client work so the terms are clear and you don’t hit rate limits during a demo.
Model names change. Google retires and reroutes Gemini models often. If gemini-3.5-flash-lite stops working, set MENU_MODEL to the current Flash-Lite model and update PRICE_IN/PRICE_OUT so the cost line stays accurate.
Dead QR codes kill trust. If you stop maintaining a client’s menu, the QR code on their tables still points at your Cloudflare account. Put the project in the client’s own Cloudflare account, or agree in writing what happens when they stop paying. A QR code that leads to an error page is worse than no QR code.
Final checklist
- Clear, glare-free photos, one per page
- Every price in
menu.csvchecked against the paper menu - Glossary with the owner’s signature dishes
-
translations.csvreviewed; unreviewed languages labelled as machine-translated - Tax/service note confirmed with the owner
- Page tested on a phone in every language
- Printed QR proof scanned with an iPhone and an Android phone
- Owner has signed off, and it’s agreed who owns the Cloudflare project
Start with one café you already go to. Once you’ve delivered one menu you’d be proud to show, you have a demo, a case study and a reason to walk into the next place.