●TTS — Gemini 3.8 Flash TTS and Flash-Lite TTS are GA as of September 22. Voice replication works on Flash only; Flash-Lite returns a 400●2.5 — Access to the 2.5 models is now limited to accounts with prior active usage (September 18). Not a deprecation: new projects should start on 3.5 Flash-Lite or 3.8 Flash●9/30 — Two days until gemini-omni-flash-preview shuts down, and gemini-2.5-flash-image follows on October 2. In practice the replacement for the latter is gemini-3.1-flash-image●AUTH — Reports continue of Gemini CLI sign-in failing only for Workspace Enterprise accounts while personal accounts work. Isolating the two is the current focus●NEW — Moving to 3.8 Flash-Lite TTS meant re-picking the voice for our guidance audio●429 — A brand-new project shows Free tier yet returns 429 with limit: 0. Here is what to check in the first hour●TTS — Gemini 3.8 Flash TTS and Flash-Lite TTS are GA as of September 22. Voice replication works on Flash only; Flash-Lite returns a 400●2.5 — Access to the 2.5 models is now limited to accounts with prior active usage (September 18). Not a deprecation: new projects should start on 3.5 Flash-Lite or 3.8 Flash●9/30 — Two days until gemini-omni-flash-preview shuts down, and gemini-2.5-flash-image follows on October 2. In practice the replacement for the latter is gemini-3.1-flash-image●AUTH — Reports continue of Gemini CLI sign-in failing only for Workspace Enterprise accounts while personal accounts work. Isolating the two is the current focus●NEW — Moving to 3.8 Flash-Lite TTS meant re-picking the voice for our guidance audio●429 — A brand-new project shows Free tier yet returns 429 with limit: 0. Here is what to check in the first hour
The Size Column Said 'F6' and 'About A4' — I Let Gemini Read It, and Let a Table Do the Math
An artist's spreadsheet mixed 'F6', '41×31.8cm', and 'about A4' in one size column. Here is the small tool I built: Gemini reads each cell, a lookup table converts to centimeters, and the artwork image decides the orientation. Working code, step by step.
The artist's spreadsheet arrived on a Tuesday afternoon, and I read the size column from top to bottom before I touched anything else. I maintain the official site for a mandala artist as client work, and the site is built so that this one spreadsheet is the source of truth: new pieces, price changes, sold-out flags, all of it flows from the ledger. Title, price, availability — those columns could be pasted straight through.
The size column could not. "F6" sat next to "41×31.8cm", which sat above "about A4", which sat above "30cm square". To the artist these are all the same kind of statement. To me they were four different formats.
The site has exactly one field: width × height in centimeters. Fixing forty rows by hand takes an hour, and the next delivery brings the same hour back. So I decided to write a small tool around the Gemini API — but only after drawing one line first.
The first thing I want to say: I did not ask the model to convert
My first version was the obvious one. I handed each string to the model and asked for centimeters. The numbers that came back looked right, and most of them were right. The trouble was that a wrong row looked exactly like a right row. Whether "F8" came back as 45.5 × 38.0 or 45.5 × 39.0, I could not tell without opening the canvas-size chart and checking by hand. If I have to check forty rows every time, I may as well have typed them myself.
So I changed what I was asking for, from conversion to reading. Is this string a canvas size, a paper size, centimeters written out, or something the model cannot classify? That is where the model's job ends. The conversion to centimeters comes from a lookup table on my side, and a table has no way to be wrong.
Gemini reads, the table converts, the image decides orientation. Because I settled that line before writing anything, the code stayed short.
What was actually in the column
I counted the formats. Roughly, it broke down like this.
Format
Examples
Share (approx.)
What it needs
Japanese canvas sizes
F6 / F10 / S6
about 40%
lookup in a canvas table
Centimeters written out
41×31.8cm / 30cm square
about 35%
transcribe the numbers as written
Paper sizes
A4 / B5
about 15%
lookup in a paper table
Vague
about A4 / postcard-sized
about 10%
read it, then flag for review
Within those four, the spelling kept branching: with or without the "号" suffix, full-width versus half-width digits, "×" versus "x" versus "*". I started writing regular expressions, stalled on the third branch, and that was the moment I decided to let the model do the reading.
One piece of preparation comes before any of this. The artist's sheet has a two-row header and a few merged cells, and if you pass it in that shape, the first thing that breaks is the row alignment. I wrote about that separately in Your Spreadsheet Breaks Before Gemini Ever Sees It, so here I will only note that I flatten the sheet to two columns — a row number and the raw size string — before anything reaches the API.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to turn a size column that mixes 'F6', 'about A4', and '30cm square' into width and height in centimeters, 40 rows in a single API call
✦You will be able to prevent the hard-to-spot failure where the model converts 39 rows correctly and one row wrongly, by drawing the line between reading and converting before you write any code
✦You will be able to catch dropped or duplicated rows and settle portrait-versus-landscape without asking the model, so the next delivery costs you a few flagged rows instead of an hour of hand edits
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Step 1: Decide the shape of a "reading" before you call anything
Left to itself, the model changes the shape of its answer from row to row. Fix the shape first and make it the only shape it can return. Gemini's structured output accepts a Pydantic model directly as response_schema, so the type definition doubles as the spec (the official reference is the Structured output page).
# size_schema.py — the shape of one reading. No conversion lives here.from enum import Enumfrom typing import Optionalfrom pydantic import BaseModel, Fieldclass Kind(str, Enum): canvas = "canvas" # Japanese canvas sizes such as F6, P10 paper = "paper" # paper sizes such as A4, B5 cm = "cm" # centimeters written out, e.g. 41×31.8cm unknown = "unknown" # unreadable or undecidableclass SizeRead(BaseModel): row_id: int = Field(description="Return the input row number unchanged") kind: Kind series: Optional[str] = Field( default=None, description="F/P/M/S or A/B. Only when kind is canvas/paper" ) number: Optional[int] = Field( default=None, description="The canvas or paper number. Only when kind is canvas/paper" ) first_cm: Optional[float] = Field( default=None, description="When kind is cm: the first number, in the order written" ) second_cm: Optional[float] = Field( default=None, description="When kind is cm: the second number. For '30cm square' repeat the first" ) approximate: bool = Field(description="true if the cell says 'about', 'approx.', or similar") note: str = Field(description="Anything you were unsure about. Empty string if nothing")
I deliberately avoided the names width and height and used first_cm and second_cm instead. At this point nobody knows whether "41×31.8" is width × height or height × width. It is a small refusal to assume, and it pays off two steps later.
Step 2: Read all forty rows in one call, then count what came back
Calling once per row means forty round trips. Numbering the rows and sending them together means one call, and at 3.8 Flash's introductory pricing (0.75 dollars per million input tokens, 3.75 per million output) a batch this size costs less than a cent.
When I batch like this, there is one check I never leave out: comparing the row IDs that came back against the ones I sent. The model can drop a row, or return the same row twice. It can even return the right count with a duplicated ID inside it. I've kept this same check in the other small batches I run as an indie developer, and it has earned its place.
# read_sizes.py — one call for every row, then reconcile the row IDsimport osfrom google import genaifrom google.genai import typesfrom pydantic import TypeAdapterfrom size_schema import SizeReadclient = genai.Client(api_key=os.environ["GEMINI_API_KEY"])MODEL = "gemini-3.8-flash"INSTRUCTION = """You read the size column of an artwork ledger.For each row, decide which kind of notation it uses and transcribe the numbers as written.Do NOT convert to centimeters. For canvas and paper sizes, split into series and number only.If the cell says 'about', 'approx.', or similar, set approximate to true.Return every input row, and keep row_id exactly as given."""def read_sizes(rows: list[tuple[int, str]]) -> list[SizeRead]: body = "\n".join(f"{row_id}\t{raw}" for row_id, raw in rows) res = client.models.generate_content( model=MODEL, contents=f"{INSTRUCTION}\n\nrow_id\tsize\n{body}", config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=list[SizeRead], temperature=0, ), ) parsed = TypeAdapter(list[SizeRead]).validate_json(res.text) # Reconcile sent vs. returned rows. Skip this and a missing row sails through. sent = {row_id for row_id, _ in rows} got = [p.row_id for p in parsed] if sorted(got) != sorted(sent): missing = sent - set(got) dup = {r for r in got if got.count(r) > 1} raise RuntimeError(f"Row alignment broke: missing={missing} dup={dup}") return sorted(parsed, key=lambda p: p.row_id)
temperature=0 is there because I want the same ledger to produce the same readings next week. Reading needs no creativity.
I also validate res.text myself rather than trusting res.parsed, because an answer can pass the schema and still be broken in meaning — kind set to canvas with number left empty, for instance. The next step is where that meaning gets checked.
Step 3: Convert with a table, orient with the image
The conversion is a lookup. The table below covers only what showed up in my ledger; add rows for whatever your artist works in. Values are the Japanese canvas standard, long side × short side.
# convert.py — the table converts, the image decides orientationfrom pathlib import Pathfrom typing import Optionalfrom PIL import Imagefrom size_schema import SizeRead, Kind# Canvas table: (series, number) -> (long side cm, short side cm)CANVAS = { ("F", 0): (18.0, 14.0), ("F", 3): (27.3, 22.0), ("F", 4): (33.3, 24.2), ("F", 6): (41.0, 31.8), ("F", 8): (45.5, 38.0), ("F", 10): (53.0, 45.5), ("F", 12): (60.6, 50.0), ("F", 15): (65.2, 53.0), ("F", 20): (72.7, 60.6), ("F", 30): (91.0, 72.7), ("P", 6): (41.0, 27.3), ("P", 10): (53.0, 41.0), ("M", 6): (41.0, 24.2), ("M", 10): (53.0, 33.3), ("S", 6): (41.0, 41.0), ("S", 10): (53.0, 53.0),}PAPER = { ("A", 3): (42.0, 29.7), ("A", 4): (29.7, 21.0), ("A", 5): (21.0, 14.8), ("B", 4): (36.4, 25.7), ("B", 5): (25.7, 18.2),}def pair_cm(r: SizeRead) -> Optional[tuple[float, float]]: """Return (long side, short side) from a reading, or None if it can't be resolved.""" if r.kind == Kind.cm and r.first_cm and r.second_cm: a, b = float(r.first_cm), float(r.second_cm) return (max(a, b), min(a, b)) if r.kind == Kind.canvas and r.series and r.number is not None: return CANVAS.get((r.series.upper(), r.number)) if r.kind == Kind.paper and r.series and r.number is not None: return PAPER.get((r.series.upper(), r.number)) return Nonedef with_orientation(pair: tuple[float, float], image: Path) -> tuple[float, float]: """Reorder to (width, height) using the artwork image's aspect ratio.""" long_cm, short_cm = pair with Image.open(image) as im: w, h = im.size return (long_cm, short_cm) if w >= h else (short_cm, long_cm)
A row where pair_cm returns None is either a canvas size missing from the table or a cell the model could not read. In this case I do not raise; I let None flow through and collect those rows into a "needs review" list at the end. If three rows out of forty need review, I ask the artist about three rows. That is a good trade.
Step 4: Run it end to end, ledger to JSON
The last piece reads the spreadsheet, runs the three stages — read, convert, orient — and writes both the JSON the site consumes and the review list.
# build.py — ledger -> readings -> conversion -> JSONimport jsonfrom pathlib import Pathfrom openpyxl import load_workbookfrom read_sizes import read_sizesfrom convert import pair_cm, with_orientationLEDGER = Path("artworks.xlsx")IMAGES = Path("images") # one {artwork_id}.jpg per pieceOUT = Path("artworks.json")wb = load_workbook(LEDGER, data_only=True)ws = wb["Works"]rows, meta = [], {}for row_id, row in enumerate(ws.iter_rows(min_row=3, values_only=True), start=1): artwork_id, title, size_raw = row[0], row[1], row[3] if not artwork_id: continue rows.append((row_id, str(size_raw or "").strip())) meta[row_id] = {"id": str(artwork_id), "title": str(title)}reads = read_sizes(rows)raw_by_id = dict(rows)out, review = [], []for r in reads: m = meta[r.row_id] pair = pair_cm(r) image = IMAGES / f"{m['id']}.jpg" if pair is None or not image.exists(): review.append({**m, "raw": raw_by_id[r.row_id], "note": r.note or "not in table / no image"}) continue w_cm, h_cm = with_orientation(pair, image) out.append({ **m, "width_cm": w_cm, "height_cm": h_cm, "approximate": r.approximate, # the site prefixes "approx." when true "size_raw": raw_by_id[r.row_id], # keep the original string })OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")print(f"resolved {len(out)} rows / needs review {len(review)} rows")for item in review: print(" -", item["id"], item["title"], "|", item["raw"], "|", item["note"])
size_raw stays in the JSON on purpose. When the artist later says "that size is wrong", I want to trace back to the exact string she wrote, not just the number I derived. Keep only the converted value and you lose the origin of the mistake.
Rows with approximate set to true are displayed as "approx. 29.7 × 21.0 cm". Printing "about A4" as a bare "29.7 × 21.0 cm" would be a stronger claim than the artist herself made, and I'd rather not put words in her mouth.
The part that surprised me: the size column never tells you the orientation
This was the pitfall I had walked straight past. A cell that says "F6" says nothing about whether the piece is portrait or landscape. The canvas table gives you the long side and the short side, and which one is the width has to come from somewhere else.
My first instinct was to ask the model to judge orientation too. But I was only sending it a string. It has no way to know, and if you ask a question it cannot answer, you get a plausible answer anyway.
The thing that knows the orientation is the artwork image. The photos arrive with the ledger, and their aspect ratio settles width versus height. That is why with_orientation opens the image, and why the model is kept out of that decision entirely.
Put simply, the tool splits three sources across three jobs: the model interprets the string, the table supplies the numbers, the image supplies the orientation — and each one only does the thing it is good at.
Where I stumbled, and what I changed
Three places gave me trouble between the first draft and the first clean end-to-end run.
kind came back as canvas with number empty. The schema allows it, since number is Optional. The fix was to route any row where pair_cm returns None into the review list instead of raising. Raising would take the other thirty-nine rows down with it.
"30cm square" came back with second_cm empty. Adding one clause to the second_cm description — "for '30cm square' repeat the first" — settled it. Field descriptions in the schema are read as instructions, and they are cheaper than a longer prompt.
Half-width "x" and full-width "×" produced inconsistent readings. This one was not the model's fault. I had not normalized the text while flattening the sheet. Passing each cell through unicodedata.normalize("NFKC", raw) first, the drift went away.
None of the three fixes made the model smarter. All three fixed something on my side, either before the call or after it. Looking back, that is exactly because I had limited the model's job to reading — the places that needed fixing stayed where I could reach them.
Pull ten rows of the size column out of whatever ledger you have and hand them to read_sizes. Just reading the kind and note fields that come back will show you which notations are mixed into that sheet. Filling in the table can wait until after that.
Decide what the model is allowed to do before writing the first line — that order is the one thing I intend to keep on the next client project, whatever the spreadsheet looks like.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.