oyware [email protected]

A restaurant brand drawn in Python

Logo, stickers, bags, a locking taco box and a website — every file generated by a script, and every claim about it checked by one

2026-09-25Python, potrace, shapely, PyMuPDF, Next.js 16, Tailwind 4

Burrito District is a small restaurant in Amman that I am involved with. On 18 September it had a logo — one AI-generated JPEG sent over WhatsApp — and nothing else. No vector file, no packaging, no website, no colour rules.

A week later it has a vector logo, a locking taco box on a real dieline, a paper bag, two plastic bags, two bag seals, six sandwich stickers, a print package with a spec sheet per item, a QR code, an installable font, and a bilingual website on staging. Nothing in that list was drawn by hand. Every file comes out of about 5,500 lines of Python in one folder, and changing a design means editing a script and re-running it.

That was not a principle at the start. It became one after the first round of feedback, when "same thing, but terracotta" turned out to be a one-line change instead of an afternoon.

What this covers


Why code, not Illustrator

I don't own Illustrator and I am not a designer. What I can do is describe a shape precisely. A brand identity turns out to be mostly precise description: this hat, at this width, this far from the fold, in these two inks, on a 60 mm circle with 3 mm bleed.

Hand-drawn Generated
Recolouring 30 mockups takes a day one constant, one re-run
"Move the logo down 5 cm" touches every file one number in one builder
Colours drift between files one colour table, one source
Nobody can prove the print file has no stray RGB a check can, and does
The option you rejected is gone it is still one function call

The last row mattered more than I expected. The restaurant picked the layout of the final taco box from a round of options three rounds earlier. Because the old round was a function, not a pile of PNGs, bringing it back in the new colours took minutes.


Step one: a vector logo from a WhatsApp JPEG

The logo was a sombrero over arched lettering, four flat colours, delivered as a compressed JPEG. Every edge had JPEG noise on it and every boundary between two browns had an anti-aliased rim of a third colour.

logo-trace/trace.py turns it into vectors in five steps:

  1. Measure the palette from "core" pixels only — each rough colour region eroded by 3 px so edges don't pull the median. Result: black #000000, dark brown #250d01, brown #3e1d0b, cream #efd9b4.
  2. Upscale 4× and classify. A bilateral filter first, to knock down the JPEG mosquito noise, then each pixel is assigned a class by its red channel. The red channel alone separates all four colours, which made thresholds simple.
  3. Kill the false rims. A thin band of class B sitting between a darker and a lighter class is anti-aliasing, not artwork. Those pixels are reassigned to whichever neighbour they are closer to.
  4. Merge specks under a size threshold into their dominant neighbour.
  5. Trace stacked layers with potrace: class ≥ 1, class ≥ 2, class ≥ 3, each traced as one silhouette and painted on top of the previous one.
layers = [(PAL[k], trace(L >= k, 'layer%d' % k)) for k in (1, 2, 3)]

Stacking is the trick. Tracing each colour as its own region leaves hairline gaps where two traced edges don't quite meet, and on a dark background those gaps show as thin light lines. Traced as stacked silhouettes, every colour sits on top of the one below and there is nothing to show through.

verify.py then renders the vector and diffs it against the source: 0.15% of pixels differ by more than 40 levels, all on edges that were blurry in the JPEG to begin with.

A traced logo is still one lump. To put the hat alone on a seal, or the words alone on a box, extract.py separates the hat from the lettering and the decorative splashes — including rebuilding the part of the hat brim that was hidden behind the letters and never existed in the JPEG.


A scene model that writes print files

bd.py, 329 lines, is the core. It is a very small graphics library:

The PDF writer is the part worth describing, because it is the part a printer actually looks at:

PDF feature Why the printer cares
DeviceCMYK fills the numbers they print are the numbers in the file
Separation colour spaces a spot ink ("BD Terracotta", "White Ink") becomes its own plate
Optional-content layers CUT, FOLD, GUIDE can be switched off and never print
TrimBox / BleedBox where the knife goes and how far the colour must run past it

The .ai files are the same PDF with a different extension — modern Illustrator opens a PDF-based .ai natively. I have no Illustrator to prove it with, so a .pdf sits beside every .ai as the fallback.

Every print file is then checked by a script, not by eye: pikepdf opens it in strict mode, PyMuPDF counts what is inside. The standard is 0 embedded images and 0 fonts — every letter is outlines, so the printer needs nothing installed.

Never name a script packaging.py. It shadows the PyPI module of the same name, which pikepdf imports, and the error you get says nothing about it. The file is pack.py now.


Options, picks, print files

The restaurant does not write specifications. They look at pictures and point. So the loop that worked was:

  1. I generate a round of PNG mockups — cheap, 3D-ish, on textured paper.
  2. They pick one, or say "this layout, those colours".
  3. Only the winner becomes print files.

The mockup engine (mockups.py) is a tiny pinhole camera: a panel rendered flat is perspective-warped onto a quad, multiplied by a paper-fibre texture, shaded and dropped onto a backdrop with a shadow. It is crude, but it answers "what does this look like as a bag" in about 20 seconds per board.

The rounds, in order:

Round Options Picked
Sandwich stickers — styles B–F, then 7 palettes of the flat style 5 + 42 flat style, palette 3
Bag, round 1 (with handles) 12 three favourites
Bag, round 2 (the real bag has no handles) 20 —
Taco box directions 30 —
Two-colour concepts — bag + box + sticker + seal each 15 the logo sticker from concept 1
Two-colour logo alternatives 15 superseded by the above
Variants of the chosen logo 16 the arched clean badge
Box designs on the final logo 16 none — they went back to box option 11's layout
That layout in the final colours 12 + 3 two finalists
Paper bag in the final style 6 solid hat, full front
Clear plastic bag 4 both types

Two lessons from running this loop:


The two-colour rule, and proving it

On 21 September the restaurant made the decision that shaped everything after it: terracotta and cream, and nothing else. No black, no white, no brown, no gradients.

That rule is easy to state and easy to break by accident. A shadow in 60% black. A white page showing through at the edge of a render. A brown from the old palette in a helper nobody touched. So it is enforced in two places.

In the scene, before anything is written:

def check(*docs):                       # the brief: two colours, nothing else
    for d in docs:
        for l in d.layers:
            for it in l.items:
                assert it.fill in (T, C) and it.stroke in (None, T, C)

In the pixels, after rendering. Anti-aliasing is fine — an edge pixel is a mix of the two colours — so the audit does not ask "is this pixel terracotta or cream". It asks whether the pixel lies on the line between them in RGB space, within a small tolerance. Anything off that line — grey, white, a stray brown — is a failure.

The first audit failed on every single mockup, on a one-pixel frame around the edge. The rasteriser blends the last row and column with the white page underneath. edge_fix() copies the neighbouring row over it. Invisible to the eye, and exactly the kind of thing the check exists to catch.

One-colour art on textured paper needs a real knockout. Filling "the cream parts" with cream on a kraft-textured mockup shows up as a flat, fake-looking patch. The shapes have to be subtracted from the ink so the paper itself shows through.

There was also a colour to settle. The logo JPEG's terracotta measured #a93b22; the seal they picked measured #b5452a. I flagged it rather than choosing, and their logo pick settled it: #b5452a, everywhere.


Straightening hand-drawn letters

The logo's lettering is hand-drawn — no typeface matches it. Anton with spurs added in code got close enough for the early sandwich stickers, but on the final layouts the difference was visible next to the real logo.

The only real source for those letters was the logo, where they are arched. _straighten() in variants2c.py un-arches them:

  1. Sample the tops and bottoms of every letter.
  2. Fit a curve to each — a robust quartic, so one tall letter doesn't bend it.
  3. Map every point so the top curve becomes a straight cap line and the bottom curve a straight baseline.
  4. Level each letter again individually, because a global fit leaves each one slightly tilted.
o[:, 1] = -cap * (yb - c[:, 1]) / (yb - yt)

That one line is the whole idea: a point's height becomes its fraction of the way between the fitted bottom (yb) and top (yt) at that x, times the cap height.

The result is the logo's own letters, upright, usable on one line, stacked, or set back onto a circle. DISTRICT was drawn about 10% narrower than BURRITO, which is invisible when stacked and obvious on one line, so one-line layouts widen it.

Then it became a font

The restaurant asked for the fonts, for their own use. The honest answer is that the name is not a font. So I made one: fontbd.py builds BurritoDistrictLettering-Regular.ttf with fontTools.

Glyphs Source
B U R I T O D S C traced from the logo and straightened — exact
every other capital, 0–9, punctuation Anton, widened ×1.36 to the traced proportions, corners faceted, spurred, slightly emboldened
lower case the same capitals — the style is caps only

The 1.36 is not a guess: it is the median width ratio between Anton and the traced letters over the nine letters both have. The L gets no spur — a spur on the L's stem reads as a Polish Ł.


A box that locks

The restaurant's packaging supplier sent a dieline for a flip-lid box, 300 × 160 × 70 mm inside, and asked for a side lock. Reading it closely, the original could not work as drawn: a 25 mm tuck on the end of a 70 mm lid front has nowhere to tuck into.

lockbox.py (839 lines, the largest script) rebuilds it:

And then it checks itself, by folding the exact die geometry in 3D:

Check Result
One-piece sheet 1 part, 441 × 582.5 mm
Panels only meet at their creases ok
Each slot at least 15 mm from any edge or crease 18.9 mm
Box shut: no two boards overlap 0 overlaps
Lid swung from 100° to shut, every 0.5°: lid never hits the tray pass

The output is a 1:1 dieline plus a four-page A3 tech sheet with the folding sequence drawn from the same model. The final artwork is mapped onto this real dieline — the lid rotated 180°, because the hinge is at the back — and folded in 3D once more as an orientation proof.

Don't mirror a texture to get the other side. The first back-view mockups made the left wall by mirroring the right one. That mirrors the hat and the text too. The left wall needs to be its own face.


The print package

printready.py writes one folder per item plus a 00-READ-ME-FIRST.pdf. Each folder has the print files in two versions — two spot inks and CMYK — previews, mockups and a one-page PRINT-SPEC.pdf that lists what the printer must confirm before printing.

The spec sheets carry the decisions a printer would otherwise make silently:

What the printer still has to confirm — and I say so in the files rather than guess quietly:

Open Why it matters
Box board caliper the dieline is compensated for 0.5 mm; a different board needs a re-run
A white sample box, tested with real tacos and the lock before anyone pays for a cutting die
Plastic bag sizes mine are assumptions; the art refits in minutes once the real size is known
Pantone references approximate; CMYK values are exact
Which of the two finalist boxes never chosen, so both shipped

The website

The site is on staging at burrito.oyware.com, marked noindex. It is built from the same assets as the packaging.

Piece Choice
Framework Next.js 16, App Router, static export — plain files served by Nginx on my droplet
Styling Tailwind 4 + shadcn/ui, re-pointed at two brand tokens
Motion motion for small reveals; none in the hero
Content typed files in the repo, no CMS

Two colours, again

The site obeys the same rule as the packaging. brand/tokens.css holds exactly two colours. "Dark mode" became a terracotta page ↔ cream page switch. The flavour colours exist only inside the six sticker images.

The same pixel audit runs here, in Playwright: every page, both page colours, desktop and phone, every pixel checked against the terracotta–cream line (and each sticker's own flavour lines). It passes with under 0.05% stray pixels, all lossy-WebP edge noise.

The contrast ratio that set the type scale

Terracotta on cream measures 3.96:1. That is below the 4.5:1 WCAG asks for normal text, and above the 3:1 it asks for large text.

With two colours there is no darker ink to reach for. So all text is large:

That constraint made the type scale, not the other way round. Lighthouse accessibility is 100.

The scroll hero

The brief was the Apple-style product scroll: a burrito that unwraps as you scroll and the fillings rise out. Photoreal frames needed an image-model API key that wasn't there, so the frames are illustrated, drawn by the same Python toolkit (heroanim.py), and drawn on a canvas by scroll position.

Each frame is a single alpha mask — paper opaque, ink lines as holes. The canvas fills it with the page's current ink colour, so one frame set works on both page colours. 96 frames for desktop, 48 for phones, fetched after load.

Two things learned the hard way:

Purging Cloudflare. The dashboard's API purge is blocked by Cloudflare's own WAF when called from the dashboard tab. The UI works, and on the free plan Custom Purge has a prefix option — one folder, not the whole site.

Arabic

Arabic lives under /ar/ with English at the root. There is no language redirect, because a static export cannot read the URL and, more importantly, the printed QR codes must keep opening exactly what they always opened. Two root layouts share one document component, because <html lang dir> must be on the root element.

One detail worth copying: the hero drawing doesn't mirror in right-to-left, so the filling labels keep left-to-right geometry and only the words inside them run right to left.

Missing content is a type

Most of the restaurant's content does not exist yet: prices, hours, branches, the real fillings, contact details. Rather than lorem ipsum, a missing value is a typed object:

export const tagline = todo("Q2", "One-line intro in the owners' words");

It renders as a dashed TODO box — the one place the design system allows a box, because a placeholder that blends in is a placeholder someone ships. The branches, opening hours and "open now" badge are built and hidden until the data is real.


One QR code, forever

The taco box and the bag carry a QR code, and that makes one URL permanent.

It points at /qr/ — a links page (menu, Instagram, WhatsApp, call, website) with zero JavaScript, 2.3 KB gzipped. The URL is encoded with the trailing slash so a phone on mobile data does not spend a redirect hop. Error correction is level Q (25%), so a crumpled bag or a grease mark still scans. Terracotta on cream, never the reverse: a scanner needs the code darker than its background.

qrcode2c.py doesn't just write the files. It decodes its own output with OpenCV at phone-camera size and fails if the result isn't the URL.

The consequence is the part to remember: the printed code points at the staging hostname. Whatever domain the restaurant picks later, that hostname must keep serving /qr/ — or 301 to the new one — for as long as a single box is in circulation. Links on the page can change freely; the URL cannot.


Letting agents build it

The website's first five phases were built by agents from Linear tickets: Cyrus on my Mac picks up a ticket, runs Claude Code in a worktree, pushes, and deploys to staging. A small launchd job started the next ticket whenever the previous one landed a Fixes … commit, with the model chosen by a tag in the ticket text.

It worked, with two corrections:

The trap the agents didn't catch and the deploy script now does: svgo wrote the brand SVGs as mode 600, rsync carried that to the server, and two images served 403 to every visitor for days. Every page returned 200, so the health check was happy. deploy.sh now makes the output world-readable before upload and requests every file the pages reference, not just the pages.


Online ordering, in preview

The newest part is customer-side online ordering: a basket, checkout, delivery or pickup, cash only, and an order-status page, in both languages. It is hidden from the public and has no backend yet. How the restaurant receives orders, who presses which button, printers, fees and delivery areas decide the shape of that backend, and those are questions for the owners, not for me to guess. The answers come first.


What is still open

Honestly, most of what a customer would read:

Each of these is a ticket with the question written out, waiting for an answer from the restaurant. The code side is ready for them: new content is a value in a typed file, a new sandwich sticker is one line in a list, and a new colour is one entry in one table and a re-run.

That last sentence is the whole argument for building a brand this way.