Skip to content

Instantly share code, notes, and snippets.

@Kequc
Last active October 21, 2025 16:36
Show Gist options
  • Select an option

  • Save Kequc/53c51fa1e829d3fc049cd12d32aca2e5 to your computer and use it in GitHub Desktop.

Select an option

Save Kequc/53c51fa1e829d3fc049cd12d32aca2e5 to your computer and use it in GitHub Desktop.
Generate random things and print them once a week

printbot

NOTICE: I made a newer version and put it here https://hub.docker.com/r/kequc/print-weekly-surprise


Suggested to be used in a python docker container. Along side cups, which is already set up with a connection to your printer. I used anujdater/cups because it seemed to be straight forward.

A few environment variables you should set.

PYTHON_BIN=/usr/local/bin/python // if for some reason different
CUPS_HOST=127.0.0.1:631 // your cups host
QUEUE=epson-et1810 // your cups printer queue
PRINT=1 // whether to send it otherwise just archive
PAPER=A4 // paper size either A4 or LETTER
TZ=Etc/UTC // your timezone
CRON="30 9 * * MON" // every monday at 9:30AM

Map this directory to /data in your docker's volumes. Run the scheduler.

/bin/sh -c /bin/sh /data/scheduler.sh

The container should stay up, stop the container to stop printing every cron. There's basic logging and all prints are archived as jpg. Or trigger the script immediately.

/bin/sh -c /bin/sh /data/run.sh
#!/usr/bin/env python3
import os, io, json, random, datetime, subprocess, ssl, urllib.request
from PIL import Image, ImageDraw, ImageFont
# ---------------- CONFIG ----------------
DPI = 300
PAPER = os.getenv("PAPER", "A4").upper() # 'A4' or 'LETTER'
def mm_px(mm): return round(mm / 25.4 * DPI)
if PAPER == "LETTER":
W, H = int(8.5 * DPI), int(11 * DPI)
else:
W, H = mm_px(210), mm_px(297)
MARGIN, GAP = mm_px(15), mm_px(6)
CUPS_HOST = os.getenv("CUPS_HOST", "127.0.0.1:631")
QUEUE = os.getenv("QUEUE", "epson-et1810")
PRINT = os.getenv("PRINT", "1") != "0"
OUT_DIR = "/data/history"
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
ssl_ctx = ssl._create_unverified_context()
# ------------- HELPERS ------------------
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=20, context=ssl_ctx) as r:
return r.read()
def load_font(size):
try:
return ImageFont.truetype(FONT_PATH, size)
except:
return ImageFont.load_default()
def fit_cover(img, w, h):
iw, ih = img.size
s = max(w / iw, h / ih)
img = img.resize((int(iw * s), int(ih * s)), Image.LANCZOS)
x, y = (img.width - w) // 2, (img.height - h) // 2
return img.crop((x, y, x + w, y + h))
def fit_within(img, mw, mh):
iw, ih = img.size
s = min(mw / iw, mh / ih)
return img.resize((int(iw * s), int(ih * s)), Image.LANCZOS)
def make_archive_path():
os.makedirs(OUT_DIR, exist_ok=True)
date = datetime.datetime.now().strftime("%Y-%m-%d")
for _ in range(100):
suffix = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=4))
path = os.path.join(OUT_DIR, f"{date}-{suffix}.jpg")
if not os.path.exists(path):
return path
raise RuntimeError("Failed to generate unique archive filename")
# ------------- FETCH CONTENT -------------
def random_photo():
return Image.open(io.BytesIO(get("https://picsum.photos/2200/3300.jpg"))).convert("RGB")
def random_xkcd():
latest = json.loads(get("https://xkcd.com/info.0.json"))["num"]
n = random.randint(1, latest)
info = json.loads(get(f"https://xkcd.com/{n}/info.0.json"))
img = Image.open(io.BytesIO(get(info["img"]))).convert("RGB")
return img, info.get("safe_title", "xkcd")
def random_quote_parts():
q = json.loads(get("https://api.quotable.io/random?maxLength=200"))
return q["content"], q["author"]
# ---------------- MAIN -------------------
def main():
OUT_PATH = make_archive_path()
bg = fit_cover(random_photo(), W, H)
d = ImageDraw.Draw(bg)
# Date header
date = datetime.date.today().strftime("%A, %d %B %Y")
f_date = load_font(40)
tw, th = d.textbbox((0, 0), date, font=f_date)[2:]
chip = Image.new("RGBA", (tw + 24, th + 12), (255, 255, 255, 200))
bg.paste(chip, (W - tw - 24 - MARGIN, MARGIN), chip)
d.text((W - tw - 12 - MARGIN, MARGIN + 6), date, fill=(0, 0, 0), font=f_date)
# Comic
comic, _ = random_xkcd()
top_y = MARGIN + mm_px(20)
comic = fit_within(comic, int(W * 0.74), int(H * 0.55))
cx, cy = (W - comic.width) // 2, top_y
bg.paste(comic, (cx, cy))
# Quote
quote_text, author = random_quote_parts()
qx, qy = cx, cy + comic.height + GAP
avail_w, avail_h = comic.width - 40, H - MARGIN - qy
min_size = 28
if avail_h > (min_size + 12) * 2 + 48:
for size in range(72, min_size - 1, -4):
f_q = load_font(size)
f_a = load_font(max(min_size, int(size * 0.78)))
# Wrap lines
lines, line = [], ""
for word in quote_text.split():
test = (line + " " + word).strip()
if d.textlength(test, font=f_q) > avail_w:
if line: lines.append(line)
line = word
else:
line = test
if line: lines.append(line)
line_h, author_h = size + 12, int(f_a.size + 10)
total_h = len(lines) * line_h + author_h + 48
if total_h <= avail_h:
overlay = Image.new("RGBA", (comic.width, total_h), (255, 255, 255, 190))
bg.paste(overlay, (qx, qy), overlay)
y = qy + 20
for ln in lines:
tw_ln = d.textlength(ln, font=f_q)
x = qx + (comic.width - tw_ln) // 2
d.text((x, y), ln, fill=(0, 0, 0), font=f_q)
d.text((x + 1, y), ln, fill=(0, 0, 0), font=f_q)
y += line_h
author_line = f"— {author}"
tw_a = d.textlength(author_line, font=f_a)
d.text((qx + (comic.width - tw_a) // 2, y + 8), author_line, fill=(0, 0, 0), font=f_a)
break
# CMYK patch
s, pad = mm_px(12), mm_px(10)
x0, y0 = W - pad - s * 4 - 9, H - pad - s
for i, c in enumerate([(0,255,255), (255,0,255), (255,255,0), (0,0,0)]):
d.rectangle((x0 + i * (s + 3), y0, x0 + i * (s + 3) + s, y0 + s), fill=c, outline=(40,40,40))
# Save + print
bg.save(OUT_PATH, quality=95)
print(f"[printbot] archived {OUT_PATH}")
if PRINT:
subprocess.run([
"lp", "-h", CUPS_HOST, "-d", QUEUE,
"-o", f"media={PAPER}",
"-o", "fit-to-page",
OUT_PATH
])
print("[printbot] sent to printer")
else:
print("[printbot] preview mode (PRINT=0)")
if __name__ == "__main__":
main()
#!/bin/sh
set -e
# Use Python from the official image unless overridden via env
PYTHON_BIN="${PYTHON_BIN:-/usr/local/bin/python}" # set default if empty
export PIP_ROOT_USER_ACTION=ignore # make visible to pip
apt-get update -qq
apt-get install -y --no-install-recommends cups-client fonts-dejavu-core >/dev/null
"$PYTHON_BIN" -m pip install --no-cache-dir pillow >/dev/null
mkdir -p /data/history
exec "$PYTHON_BIN" /data/print-weekly-surprise.py
#!/bin/sh
set -e
# Timezone (defaults to UTC if TZ not set in env)
export TZ="${TZ:-Etc/UTC}"
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
CRON="${CRON:-30 9 * * MON}"
apt-get update -qq
apt-get install -y --no-install-recommends cron >/dev/null
# Write cron job (no PATH hacks needed)
echo "$CRON root /bin/sh /data/run.sh >> /data/cron.log 2>&1" > /etc/cron.d/printbot
chmod 0644 /etc/cron.d/printbot
# Start cron in foreground
exec cron -f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment