Compiling pictures to CSS
CSSBattle shows you a flat picture and asks for the shortest HTML and CSS that draws it. cssynth is a compiler that plays it: give it the picture, and it searches for the code. Here is how it works, what each idea is worth, and where it still loses to people.
target
cssynth's code, rendered by Chromium
<body bgcolor=c31c1d><b style="position:fixed;inset:70px 80px 70px 80px;background:#660301;clip-path:polygon(25% 0,75% 0,100% 100%,75% 100%,75% 75%,140px 75%,140px 50%,100px 50%,100px 75%,25% 75%,25% 100%,0 100%)">
214 characters at a 99.93% pixel match. The human solution it was rendered from is 493 characters after minifying.
The game, and why it is a compiler problem
CSSBattle targets are 400×300 pictures with a handful of flat colors: boxes, circles, rounded bars, the odd triangle. You write HTML and CSS that renders the same picture, and the shortest code at a full match wins. Players golf hard: no doctype (so the page renders in quirks mode and padding:50 needs no unit), unclosed tags, colors in their shortest spelling.
It looks like image fitting, but two things make it a compiler problem. The cost is characters, not shapes: a circle is cheap, a five-point clip-path:polygon(…) costs 40–60 characters, and a shape that repeats can be a box-shadow of another for 10–20. And the renderer is fixed: Chromium decides where every edge falls and how it is smoothed, so a plan that is right in an idealized model can be wrong by a band of pixels along every edge.
One number
Everything in cssynth is scored by a single objective:
J = characters + wrong pixels / ρ
A pixel is wrong if any channel is more than 8/255 off. ρ is how many wrong pixels the compiler accepts to save one character. The default is 0.5, so two wrong pixels cost as much as a character and it almost never gives up accuracy for length. Search variants, clean-up moves and re-search steps are all accepted or rejected by J, and the character count comes from running the real code generator, not from an estimate.
A greedy search that can paint underneath
The core loop adds one shape at a time. Candidates come from the error: for every connected region of still-wrong pixels of one color, it proposes the region's box, its ellipse, boxes with various rounded corners, a 45° diamond, a bar and a leaf along the region's main axis, and polygons from the region's convex hull and from its traced outline. The most promising are hill-climbed on their edges, corner radii, rotation or vertices, and the best one goes in.
The detail that matters most: a new shape can go at any depth in the stack. For each depth, its value is computed against the canvas below that depth, counting only the pixels the shapes above leave visible. That is how rings, eyes and nested figures get built: the hole is found first, and the disc slides in underneath it.
target
cssynth, 391 characters (human: 951)
<body bgcolor=0d2328><b style="inset:139px 190px 0 190px"><b style="inset:239px 290px 0 90px;box-shadow:200px 0 #9ed7f8"><b style=left:51;top:141;padding:49;border-radius:46px><b style="left:170;top:60;padding:30;background:#0d2328;border-radius:50%;box-shadow:100px 100px #0d2328,100px 100px 0 20px #9ed7f8,-100px 100px #0d2328,0 0 0 20px #9ed7f8"><style>b{position:fixed;background:#9ed7f8
The last element is a dark disc: a spread shadow (0 0 0 20px) draws the top ring around it, and two more dark shadows punch the holes into the two light discs below.
Switching insertion at any depth off, so that new shapes always go on top, raises the total J over the benchmark by 6.9%, one of the three largest effects of any single part.
Putting length into the search
A plain greedy search picks the shape that fixes the most pixels. That ignores what it costs to write: a polygon and a rounded box that fix about as many pixels look the same, though one is twice as long. So the search has a second mode, which picks the shape with the best
pixels fixed / ρ − estimated characters
The estimate is the shape's own element, or one box-shadow entry when the shape is a shifted copy of one already placed. Repeats get cheaper as the search goes, so it learns to prefer the copy. Neither mode is always better (a cheap shape now can leave more to draw later), so every search runs both ways and J picks the winner. On the benchmark the length-aware mode wins on 50 of 66 targets. Running both cut 688 characters in total, and twice the searches take about as long as before, because the length-aware ones stop with fewer shapes.
Box-shadows as a constraint problem
Box-shadows are the golfer's copy-paste: box-shadow:35px 0 #fff,70px 0 #fff repeats a box for a few characters per copy. But a shadow is painted just below its own element, so grouping shapes as shadows of one another changes the paint order, and shapes that overlap with different colors have to keep their order.
cssynth solves the grouping and the paint order together with CP-SAT. Each shape is either an element or a shadow of one compatible element. Each gets a paint position, which a shadow shares with its anchor. For every pair that must stay in order, the positions strictly increase unless both are in the same group, which rules out cycles. The objective is the characters of the elements, the shadow entries and the box-shadow: declarations.
target
cssynth, 496 characters (human: 1599)
<body bgcolor=9ed7f8><b style=left:50;top:90;width:300;height:120;background:#277292;border-radius:20px><b style="width:20;border-radius:50%;box-shadow:35px 0 #fff,70px 0 #fff,105px 0 #fff,140px 0 #fff,0 35px #fff,35px 35px #fff,70px 35px #fff,105px 35px #fff,140px 35px #fff"><b style="left:65;width:40;box-shadow:0 35px #fff,0 70px #fff,230px 0 #fff,230px 35px #fff,230px 70px #fff"><b style=top:175;width:160><style>b{position:fixed;height:20;background:#fff;border-radius:1em;left:120;top:105
Most of the keys are shadows of two elements; the rule at the end gives every <b> the shared position, color and corners.
Keeping the model honest
The search scores shapes with its own rasterizer, because it evaluates hundreds of thousands of them. A gain is a few lookups in running sums along pixel rows, over the runs of pixels each shape covers, cached by shape and checked to give bit-identical answers to the full rasterizer. But the benchmark scores what Chromium actually draws: every output is rendered in headless Chromium and compared with the target, pixel by pixel. Where the two disagreed, the model was fixed rather than tuned around:
- Chromium rounds a rotated box's position to whole pixels before rotating it.
- A spread larger than a corner radius bends the shadow's corner, so those shapes never become shadows.
- Chromium evaluates gradients at pixel centers and does not smooth hard stops. Targets drawn with rotated boxes have smoothed edges, so a hard-stop gradient leaves a band of wrong pixels along each one. cssynth fits each boundary to a sub-pixel position with a short linear ramp, which Chromium draws within 3/255 of the model.
target
cssynth, 935 characters (human: 971)
The background is a linear-gradient fitted by dynamic programming over every angle; turned copies of a rounded bar share one <u> rule; polygons that mirror each other share a clip-path and flip with scale:-1.
What each part buys
Switching off one part at a time and recompiling all 66 targets (scored with cssynth's own rasterizer; the parts overlap, so the rows don't add up):
Results
The benchmark is 66 targets rendered from public human solutions, so every target is exactly reproducible. cssynth's code is shorter than the minified human solution on 56 of 66, 23,329 characters in total against 35,532, and 66 of 66 targets render at a 99% or better pixel match in Chromium. Those human solutions are readable code, not golfed; top CSSBattle players write far shorter code, so this is a baseline, not a leaderboard. Every target, output and program is on the results page.
Away from the benchmark
Those 66 targets are where the compiler was developed, so they can't show how it generalizes. Two more benchmarks do. The first is 48 targets generated from six structures (mirrored shapes, repeats, concentric rings, petals, stripes, unrelated shapes), compiled once per version and never inspected. The second is tiny stacks of grid-aligned boxes and ellipses, where an exact solver finds the shortest program cssynth's own code generator can write.
The held-out families found a blind spot. Three concentric circles need a filled disc first, and that disc on its own breaks every correct pixel inside it, so a search that adds one shape at a time never places it. A nested-ring motif fills a ring's hole, fits a shape to the filled region, recurses into the hole, and offers the whole stack as one step. Rings went from 5 of 8 solved to 7 of 8 on the held-out set. Over all 48, 43 are solved against 40, with less CPU time than v0.5.0 given 40% more search. On the tiny targets, cssynth's code is provably optimal on 33 of the 35 it reproduces exactly.
Not everything transferred. Mirror and turn motifs made as many targets longer as shorter. A filter learned from the outcome of 989 proposals didn't recognize structures it hadn't been trained on. And petals stay weak, because Chromium smooths curved, turned edges differently from cssynth's model, so the right program looks wrong to the search. The details are in the method notes.
Where it still loses
The greedy search commits to the shape that looks best now. A re-search pass takes the best result apart (one polygon at a time, then one color at a time), refills the gap and keeps whatever lowers J, which repairs some of those choices, but not the structural ones.
target
cssynth, 706 characters (human: 481)
<body bgcolor=2e0040><b style=left:100;width:200;height:100;border-radius:4em><b style=left:150;top:50;width:100;height:200;background:#d3b1d6;border-radius:4em><b style=left:227;top:132;width:49;height:41;rotate:46deg></b><b style=left:143;top:178;rotate:-72deg></b><b style=left:142;top:115;rotate:71deg></b><b style=top:114;rotate:-75deg></b><b style=top:182;rotate:75deg></b><u style=left:207></u><u style="left:171;scale:-1 1"></u><b style="left:130;width:50;height:100;clip-path:polygon(4% 98%,8% 2%,96% 49%,98% 51%)"><style>b{position:fixed;width:18;height:4;background:#a94ea4;left:240;top:100}u{position:fixed;width:22;height:13;background:#a94ea4;top:143;clip-path:polygon(22px 0,19px 13px,0 7px)
A person draws an oval and two circles. The search sees one tall rounded box that covers both circles at once, fixes more pixels than either circle, and takes it. Then it patches the purple waist the box covered with small slivers.
Hill climbing makes this hard to fix locally: start from one circle, and the climb grows it back into the tall box, because that fixes more pixels. What would find the human plan is a search that compares whole programs: two circles plus a cheap shadow against one box plus patches. That is the next step, along with gradients on shapes, borders and repeating a shape with ::before/::after.
Try it
The compiler is pure Python (NumPy and SciPy, with OR-Tools for the shadow grouping when it is installed), so it also runs in the browser. The demo loads it with Pyodide and compiles your picture locally. The source, the benchmark and a longer write-up of the method are on GitHub.