Prototype · image-to-CSS compiler

cssynth

Give it a target image from CSSBattle, the code-golf game where you redraw a 400×300 picture in as few characters of HTML and CSS as you can. It returns the code. Every result below was rendered by Chromium from the generated code and compared pixel by pixel with the target.

Try it on your own picture, in your browser · How it works · Source

66/66targets at 99% match or better (66 at 98%+)
327median characters on those
480median for the human solutions, minified
56of those shorter than the human code
64 → 66targets at 99%+ match, first version → now
10 → 22targets at a 100% match
34,963 → 23,329characters across all targets
−33%total code length since the first version

Match counts pixels whose color is within 8/255 per channel of the target. The human solutions come from two public GitHub repos and are written to be readable, not golfed. Top CSSBattle players are far shorter, so these lengths are a baseline, not a leaderboard. All results, including the trade-off table, are rendered on Linux with the same Chromium build as the targets.

What changed since the first version

Re-search and soft gradient stops

The greedy search commits to shapes in the order they first help. The best result is now taken apart and refilled, one polygon or one color at a time, and kept when the score drops; Pythagoras went from 330 to 240 characters. Chromium doesn't antialias hard gradient stops, so a flag whose bands were drawn with rotated boxes used to leave a band of wrong pixels along every edge. Each stop is now fitted to a sub-pixel position with a short ramp, which Chromium draws within 3/255 of the model: Tanzania became a single gradient, 602 → 142 characters (human: 336). A polygon that mirrors another now reuses its clip-path with scale:-1 1.

Search fixes, shared rules, faster

Shapes that touch only at a corner used to count as one region, which no single shape fits; each edge-connected part is now proposed on its own, and Ups & Downs went from 831 to 207 characters (the human solution is 406). A leaf, a square rounded on two opposite corners and turned onto the region's axis, joined the candidates. Alike elements can now share their own tag and rule (<i>, <u>, <s>), which also lets turned copies of a shape share their CSS. Box gains come from a summed-area table, a polygon vertex move only re-tests nearby pixels, and one search now serves two thresholds: the whole benchmark runs in less than half the time (70 to 33 CPU-minutes on the same machine).

Outline polygons

Polygons used to come only from a region's convex hull, with at most five vertices, so a notched or many-sided figure was tiled from slivers. The search now also traces each region's outline, which may be concave, and simplifies it to 4–12 vertices; a clean-up move then drops vertices while that lowers the score. Missing Piece went from 1,887 to 336 characters, shorter than the human solution.

Gradient backgrounds

For striped pictures the compiler fits a hard-stop linear-gradient at any angle: every pixel is projected onto the gradient line, and dynamic programming picks the band boundaries that minimize wrong pixels plus a cost per stop. Shapes are then layered on top as before. 9 targets use one.

Exactly optimal shadow grouping

Choosing which shapes become box-shadows of which, together with a valid paint order, is now solved exactly with a constraint solver: each shape gets a paint position, shadows share their anchor's, and every pair that must stay in order needs strictly increasing positions unless they share a group. Of 30 targets with any grouping, it beat the greedy grouping on 7 (42 characters) and proved optimality on 28.

Scoring by code length

Every variant is now judged by one objective: characters + wrong pixels ÷ ρ. A clean-up pass drops shapes, snaps near-equal shapes to identical sizes so they can share CSS, and evens out sizes for the shorter padding form, keeping each move only if the objective improves. The shared style element also moved to the end of the document, unclosed, which saves 9 characters wherever it is used.

What the compiler found

All 66 targets

What each part buys

Each row recompiles all 66 targets with one technique switched off. Scores come from the compiler's own rasterizer rather than Chromium, and the parts overlap, so the rows don't add up. Inserting shapes below existing ones and re-searching the result matter most.

Switched offTotal JCharactersShorter than human
nothing (full compiler)60,44123,32956 / 66
Insertion at any depth64,637 (+6.9%)24,887 (+1,558)54 / 66
Polygons traced from outlines64,449 (+6.6%)25,791 (+2,462)52 / 66
Re-search of the best result64,444 (+6.6%)24,652 (+1,323)55 / 66
Gradient backgrounds63,265 (+4.7%)24,077 (+748)54 / 66
Length-aware shape choice62,921 (+4.1%)24,017 (+688)54 / 66
Length-aware clean-up61,982 (+2.5%)24,914 (+1,585)52 / 66
Splitting regions that touch at corners61,705 (+2.1%)24,005 (+676)55 / 66
Nested-ring motifs61,309 (+1.4%)23,495 (+166)56 / 66
Box-shadows61,258 (+1.4%)24,150 (+821)56 / 66
Soft gradient stops61,106 (+1.1%)23,680 (+351)55 / 66
Per-tag rules (<i>, <u>, <s>)60,807 (+0.6%)23,583 (+254)55 / 66
clip-path reuse for copies and mirrors60,708 (+0.4%)23,544 (+215)55 / 66
Exact shadow grouping (greedy kept)60,554 (+0.2%)23,442 (+113)56 / 66

Trading match for length

ρ is how many wrong pixels the compiler accepts to save one character. CSSBattle tells players to reach a 100% match before cutting characters, so the default is cautious. Raising ρ golfs harder. Each row below is the whole benchmark re-run at that setting, rendered in Chromium and scored.

SettingTargets at 99%+At 99.5%+Total charactersMedian match
default (ρ = 0.5)665923,32999.90%
ρ = 0.25665923,32999.90%
ρ = 1665922,562 (−3%)99.90%
ρ = 2665922,013 (−6%)99.89%
ρ = 4665921,452 (−8%)99.83%

How it works

  1. Palette. Find the flat colors and assign each pixel to one. A color that sits between two others counts as antialiasing only if it forms thin lines; a solid patch of it is a real fill.
  2. Base layer. A flat background color, or for striped pictures a hard-stop linear gradient fitted at any angle.
  3. Layered search. Repeatedly add the shape that fixes the most wrong pixels: a box with any corner radii, an ellipse, a rotated bar or leaf, or a polygon clipped with clip-path, fitted to the region's convex hull or traced from its outline, concave or not. A new shape can go at any depth in the stack, not just on top. That is how rings, eyes and other nested figures get built.
  4. Refine. Merge two pieces of the same color into one shape when that costs almost nothing, re-fit every shape against its final surroundings, drop shapes that no longer pay for their characters, and round coordinates where that changes no pixels.
  5. Code generation. A shape that is a shifted, uniformly grown or shrunk copy of another becomes a box-shadow of it, when the paint order allows and CSS keeps the shape exact (a spread larger than the corner radius bends the corners). The grouping and the paint order are chosen together by an exact solver. Boxes are sized with unitless padding (allowed in quirks mode), saturated corner radii get short spellings like 7em, and properties shared by every element move into one rule in a trailing, unclosed <style>; families of alike elements can take their own tag and rule.
  6. Choose. Eight search settings run per image (six searches, two of which also stop early at a higher threshold); each result is cleaned up for length and scored by characters + wrong pixels ÷ ρ. The lowest score wins.
  7. Re-search. The winner is taken apart, one polygon or one color at a time, refilled by the same search, cleaned up again and kept when its score drops.

Limits of this prototype