#!/usr/bin/env python3
"""Draws "Marcus at his laptop" as pixel art and writes it as an SVG fragment.

The scene is described with simple shapes (ellipses, polygons, rounded
rectangles) in a 64 x 40 design space, rasterised without anti-aliasing at
SCALE times that resolution, and written as <rect> runs with
shape-rendering="crispEdges". SCALE sets how "pixely" it looks: 1 is chunky
8-bit, 3 (default) is finer 16-bit style detail.

Fixed colours are the character (skin, hair, clothes); "token" colours are CSS
classes the theme maps to its design tokens, so the scene follows light and
dark mode.

Usage:
  python3 scripts/pixel-art/marcus_at_laptop.py [SCALE] \
    > web/themes/custom/marcus_theme/templates/partials/pixel-scene.svg.twig
  python3 scripts/pixel-art/marcus_at_laptop.py --png \
    web/themes/custom/marcus_theme/images/default-preview.png
  python3 scripts/pixel-art/marcus_at_laptop.py --png \
    web/themes/custom/marcus_theme/images/podcast-cover.png 1400x1400
The --png form writes a PNG in the light-mode colours: 1200 x 630 by default
(the default preview image for social cards and listings), or any WIDTHxHEIGHT
(the square podcast cover).
Requires Pillow.
"""

import sys

from PIL import Image, ImageDraw

PNG_OUT = sys.argv[2] if len(sys.argv) > 2 and sys.argv[1] == "--png" else None
SCALE = 3 if PNG_OUT or len(sys.argv) < 2 else int(sys.argv[1])
BASE_W, BASE_H = 64, 40
W, H = BASE_W * SCALE, BASE_H * SCALE

# Fixed character colours and themable token classes, each with an index.
FIXED = {
    "hair": "#1d1b24", "hair-hi": "#3d3950", "skin": "#f1c7a0", "skin-sh": "#d9a57c",
    "eye": "#1d1b24", "white": "#ffffff", "mouth": "#a8483f", "shirt": "#17161d",
    "shirt-hi": "#2c2b37", "print": "#e9e9f0", "pants": "#6b7280", "pants-sh": "#4b5563",
    "shoe": "#24232b", "watch": "#101015", "chair": "#3f4552", "chair-hi": "#5a6170",
    "lid": "#aab3c2", "lid-sh": "#7d8696", "key": "#cdd3dd", "key-sh": "#a3abb8",
}
TOKENS = ["desk", "desk-sh", "glow", "spark", "spark2", "plant", "plant-sh", "pot", "bubble",
          "bubble-ink", "floor", "mug", "mug-sh"]
NAMES = list(FIXED) + TOKENS
INDEX = {name: i + 1 for i, name in enumerate(NAMES)}

img = Image.new("P", (W, H), 0)
d = ImageDraw.Draw(img)


def s(v):
    return round(v * SCALE)


def rect(x, y, w, h, c, r=0):
    x0, y0 = s(x), s(y)
    box = [x0, y0, max(x0, s(x + w) - 1), max(y0, s(y + h) - 1)]
    if r:
        d.rounded_rectangle(box, radius=s(r), fill=INDEX[c])
    else:
        d.rectangle(box, fill=INDEX[c])


def ellipse(x, y, w, h, c):
    x0, y0 = s(x), s(y)
    d.ellipse([x0, y0, max(x0, s(x + w) - 1), max(y0, s(y + h) - 1)], fill=INDEX[c])


def poly(points, c):
    d.polygon([(s(x), s(y)) for x, y in points], fill=INDEX[c])


def line(points, c, width=0.5):
    d.line([(s(x), s(y)) for x, y in points], fill=INDEX[c], width=max(1, s(width)))


def dot(x, y, c, size=0.5):
    rect(x, y, size, size, c)


# Floor.
rect(4, 38, 56, 0.5, "floor")

# Desk.
rect(30, 24, 30, 2, "desk", r=0.5)
rect(30, 25.5, 30, 0.5, "desk-sh")
rect(32, 26, 1.5, 12, "desk-sh")
rect(56.5, 26, 1.5, 12, "desk-sh")

# Laptop: deck on the desk, lid seen from behind, glowing logo.
poly([(33, 23.5), (49, 23.5), (48, 22.5), (34.5, 22.5)], "key")
rect(33, 23.5, 16, 0.5, "key-sh")
poly([(46.5, 22.5), (48.5, 22.5), (50, 11.5), (48, 11.5)], "lid")
poly([(46.5, 22.5), (47.2, 22.5), (48.7, 11.5), (48, 11.5)], "lid-sh")
ellipse(47.6, 15.5, 1.6, 1.6, "glow")

# Mug with a handle and steam, and a plant in a pot.
rect(51, 19.5, 3, 4, "mug", r=0.4)
rect(51, 22.8, 3, 0.7, "mug-sh")
d.ellipse([s(53.5), s(20.3), s(55.2) - 1, s(22.5) - 1], outline=INDEX["mug"], width=max(1, s(0.5)))
line([(52, 18.5), (52.6, 17.6), (52, 16.6)], "spark2", 0.4)
line([(53.2, 18.6), (53.8, 17.6)], "spark2", 0.4)
poly([(55, 21), (58.5, 21), (58, 24), (55.5, 24)], "pot")
rect(54.8, 20.6, 3.9, 0.7, "pot")
line([(56.7, 21), (56.7, 15.5)], "plant-sh", 0.4)
ellipse(54.3, 16.8, 2.6, 1.4, "plant")
ellipse(56.9, 15.2, 2.6, 1.4, "plant")
ellipse(55.6, 13.9, 1.8, 2.4, "plant")
ellipse(57.2, 18.4, 2.2, 1.2, "plant")

# Chair.
rect(11, 16.5, 3, 12, "chair", r=1)
rect(11.5, 17, 1, 10, "chair-hi")
rect(13, 27.5, 14, 2, "chair", r=0.6)
rect(13, 27.5, 14, 0.6, "chair-hi")
rect(19, 29.5, 2, 7, "chair")
rect(15, 36.5, 10, 1, "chair", r=0.5)

# Legs: thighs on the seat, shins under the desk, shoes.
rect(15, 24.8, 15, 3, "pants", r=1)
rect(15, 26.9, 14, 0.9, "pants-sh")
rect(27, 26.5, 3.6, 10, "pants", r=0.8)
rect(27, 27, 0.9, 9.5, "pants-sh")
poly([(26.6, 36.2), (31, 36.2), (33.6, 37.2), (33.6, 38), (26.6, 38)], "shoe")

# Torso: black T-shirt with the little white print, leaning a bit forward.
poly([(16, 14.5), (23.5, 14), (25.5, 18), (25, 25.8), (15, 25.8), (14.8, 18)], "shirt")
poly([(22.6, 15), (24.2, 15.2), (25, 18), (24.5, 25), (23, 25)], "shirt-hi")
ellipse(18.2, 17.4, 2.6, 1.6, "print")
rect(18.8, 18.6, 1.4, 1.4, "print")
dot(19.2, 16.8, "print")

# Arm reaching for the keyboard, with a watch.
poly([(20.5, 15.5), (24.5, 16), (25, 20.5), (22, 21.5)], "shirt")
poly([(22, 20.2), (25, 19.8), (36, 20.9), (36, 22.6), (24, 22.8), (22, 22)], "skin")
poly([(24, 22), (36, 22), (36, 22.6), (24, 22.8)], "skin-sh")
ellipse(35.4, 20.6, 3.4, 2.2, "skin")
rect(31.6, 20.7, 1.1, 2.1, "watch")

# Neck and head.
rect(18.8, 11.5, 3.4, 3, "skin")
rect(18.8, 13, 3.4, 1, "skin-sh")
ellipse(16.4, 3.4, 10.4, 10.2, "skin")
poly([(25.4, 7), (27.6, 9.6), (25.6, 10.3)], "skin")
ellipse(16.6, 7.4, 2.4, 3.2, "skin-sh")
ellipse(17.1, 8.1, 1.4, 1.8, "skin")

# Side-swept dark hair with volume, a highlight and a sideburn.
poly([(15.2, 9.5), (14.8, 5.5), (16.2, 2.8), (19.5, 1.4), (23.5, 1.6), (26.4, 3), (28, 4.6),
      (26.2, 4.7), (24.6, 4.3), (22.4, 5.2), (19.6, 5.6), (18.6, 7.4), (18.6, 9.6), (16.8, 10.6)],
     "hair")
poly([(18.4, 2.6), (22.8, 2), (25.6, 3.1), (22.6, 3.4), (19.6, 3.8)], "hair-hi")
rect(18.6, 8.6, 0.8, 2.2, "hair")

# Eyebrow, eye with a highlight, nose, smile.
line([(22.9, 5.7), (25.0, 5.5)], "hair", 0.5)
ellipse(23.5, 7.4, 1.4, 1.5, "eye")
dot(24.3, 7.5, "white", 0.4)
d.arc([s(22.2), s(8.6), s(25.6), s(11.4)], start=25, end=150, fill=INDEX["mouth"], width=max(1, s(0.45)))

# Speech bubble with "</>" and AI sparkles.
rect(37, 2, 13, 9, "bubble", r=1.5)
poly([(40.5, 10.5), (43, 10.5), (40.8, 12.6)], "bubble")
line([(41.8, 4.6), (40, 6.5), (41.8, 8.4)], "bubble-ink", 0.6)
line([(44.8, 4.2), (42.8, 8.8)], "bubble-ink", 0.6)
line([(45.8, 4.6), (47.6, 6.5), (45.8, 8.4)], "bubble-ink", 0.6)
for cx, cy, c, size in [(54, 6, "spark", 1.2), (33, 9, "spark2", 1), (58, 11, "spark", 0.9)]:
    rect(cx - size, cy - size / 3, size * 2, size * 2 / 3, c)
    rect(cx - size / 3, cy - size, size * 2 / 3, size * 2, c)


def svg():
    px = img.load()
    out = [f'<svg class="pixel-scene" viewBox="0 0 {W} {H}" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" role="img" aria-labelledby="pixel-scene-title">',
           '<title id="pixel-scene-title">{{ alt }}</title>']
    for y in range(H):
        x = 0
        while x < W:
            i = px[x, y]
            if i == 0:
                x += 1
                continue
            start = x
            while x < W and px[x, y] == i:
                x += 1
            name = NAMES[i - 1]
            attr = f'class="px-{name}"' if name in TOKENS else f'fill="{FIXED[name]}"'
            out.append(f'<rect x="{start}" y="{y}" width="{x - start}" height="1" {attr}/>')
    out.append('</svg>')
    return "\n".join(out)


# Light-mode values of the token colours, for the PNG.
LIGHT = {
    "desk": "#c8895a", "desk-sh": "#9a6238", "glow": "#4242fa", "spark": "#e60067", "spark2": "#63bce9",
    "plant": "#008a39", "plant-sh": "#006129", "pot": "#c2410c", "bubble": "#ffffff", "bubble-ink": "#4242fa",
    "floor": "#d6d8e1", "mug": "#e60067", "mug-sh": "#a1004a",
}


def png(path, size=(1200, 630), background="#f1f3f9"):
    palette = [0, 0, 0]
    for name in NAMES:
        hexcol = (FIXED.get(name) or LIGHT[name]).lstrip("#")
        palette += [int(hexcol[i:i + 2], 16) for i in (0, 2, 4)]
    art = img.copy()
    art.putpalette(palette + [0] * (768 - len(palette)))
    art = art.convert("RGBA")
    mask = Image.new("L", img.size)
    mask.putdata([255 if v else 0 for v in img.getdata()])
    art.putalpha(mask)
    factor = int(min(size[0] * 0.92 / W, size[1] * 0.86 / H))
    art = art.resize((W * factor, H * factor), Image.NEAREST)
    canvas = Image.new("RGBA", size, background)
    canvas.alpha_composite(art, ((size[0] - art.width) // 2, (size[1] - art.height) // 2))
    canvas.convert("RGB").save(path, optimize=True)


if PNG_OUT:
    size = tuple(int(v) for v in sys.argv[3].split("x")) if len(sys.argv) > 3 else (1200, 630)
    png(PNG_OUT, size)
else:
    print(svg())
