feat: Make good art (with Typst) #20

Open
doomhammer wants to merge 4 commits from make-good-art into main
9 changed files with 394 additions and 136 deletions
Showing only changes of commit 88765f396e - Show all commits

View file

@ -18,8 +18,10 @@ dependencies = [
"cssselect==1.3.0", "cssselect==1.3.0",
"cssutils==2.11.1", "cssutils==2.11.1",
"discord.py==2.7.1", "discord.py==2.7.1",
"fastapi[standard]>=0.136.1",
"html5lib==1.1", "html5lib==1.1",
"idna==3.10", "idna==3.10",
"immichpy>=4.1.3",
"Jinja2==3.1.6", "Jinja2==3.1.6",
"lxml==6.0.0", "lxml==6.0.0",
"MarkupSafe==3.0.2", "MarkupSafe==3.0.2",
@ -31,6 +33,7 @@ dependencies = [
"six==1.17.0", "six==1.17.0",
"soupsieve==2.7", "soupsieve==2.7",
"typing_extensions==4.14.0", "typing_extensions==4.14.0",
"typst>=0.14.8",
"urllib3==2.5.0", "urllib3==2.5.0",
"webencodings==0.5.1", "webencodings==0.5.1",
] ]

View file

74
src/kronos-ui/main.py Normal file
View file

@ -0,0 +1,74 @@
from datetime import datetime
import json
import os
import shutil
from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
import typst
from kronos.mobilizon import get_raw_events
app = FastAPI()
app.mount("/static", StaticFiles(directory="src/kronos-ui/static/"), name="static")
@app.get("/", response_class=HTMLResponse)
async def root():
with open("src/kronos-ui/static/index.html", "rb") as f:
index = f.read()
return index
@app.get("/calendar", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
async def calendar():
get_raw_events()
png_bytes = typst.compile("src/typst/calendar.typ", format="png")
return Response(content=png_bytes, media_type="image/png")
@app.post("/event_card", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
async def event_card( # noqa: PLR0913
title: str = Form(""),
description: str = Form(""),
begins=Form(...),
ends=Form(...),
speaker: str = Form(""),
picture: UploadFile = File(...),
):
pic_path = os.path.join("src", "typst", "assets", "tmp", f"{datetime.now()}.jpg")
fp = open(pic_path, "wb")
if picture and picture.size > 0:
shutil.copyfileobj(picture.file, fp)
else:
with open(os.path.join("src", "typst", "assets", "photo.jpg"), "rb") as placeholder:
shutil.copyfileobj(placeholder, fp)
# TODO: pack this in a method
events = [
{
"title": title,
"beginsOn": begins,
"endsOn": ends,
"description": description,
"physicalAddress": None,
"picture": os.path.relpath(pic_path, start="src/typst"),
"url": None,
"txt_description": description,
"txt_date": f"{begins.split('T')[0]} {begins.split('T')[1]}-{ends.split('T')[1]}",
# "txt_date": "2026-04-29 18:00-19:00",
"speaker": speaker,
}
]
sys_inputs = {"events": json.dumps(events)}
print(sys_inputs)
# FIXME: remove hardcoded paths
png_bytes = typst.compile("src/typst/event.typ", format="png", sys_inputs=sys_inputs)
fp.close()
return Response(content=png_bytes, media_type="image/png")

3
src/kronos-ui/setzer.py Normal file
View file

@ -0,0 +1,3 @@
import typst
typst.compile("src/typst/calendar.typ", output="calendar.pdf")

View file

@ -0,0 +1,164 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="description" content="Kronos Generator" />
<title>Kronos UI -- Generator Genialnych Grafik</title>
<link rel="shortcut icon" href="static/favicon.ico" />
<!-- Font Awesome CSS -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<!-- Bootstrap CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity=""
/>
<link rel="stylesheet" href="static/css/style.css" />
<!-- DataTables CSS -->
<link
rel="stylesheet"
href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css"
/>
<style>
* {
margin: 0;
padding: 0;
}
body {
background: white;
}
.container {
border: 1px solid grey;
margin: 1rem;
}
[data-tab-info] {
display: none;
}
.active[data-tab-info] {
display: block;
}
.tab-content {
margin-top: 1rem;
padding-left: 1rem;
font-size: 20px;
font-family: sans-serif;
font-weight: bold;
color: rgb(0, 0, 0);
}
.tabs {
border-bottom: 1px solid grey;
background-color: rgb(16, 153, 9);
font-size: 25px;
color: rgb(0, 0, 0);
display: flex;
margin: 0;
}
.tabs span {
background: rgb(16, 153, 9);
padding: 10px;
border: 1px solid rgb(255, 255, 255);
}
.tabs span:hover {
background: rgb(55, 219, 46);
cursor: pointer;
color: black;
}
</style>
</head>
<body>
<div id="site-body" class="container-fluid">
<div class="row flex-nonwrap">
<div class="sidenav"></div>
<div class="main">
<h1>Kronos</h1>
<div class="tabs">
<span data-tab-value="#tab_1">Kalendarz</span>
<span data-tab-value="#tab_2">Karta</span>
</div>
<div class="tab-content">
<div class="tabs__tab active" id="tab_1" data-tab-info>
<h2>Kalendarz</h2>
<form action="/calendar">
<label for="calendar_begins">Początek:</label>
<input type="date" id="calendar_begins" name="begins"><br>
<input type="submit" value="Make me a calendar!">
</form>
<div>
<img src="/calendar" width="80%">
</div>
</div>
<div class="tabs__tab" id="tab_2" data-tab-info>
<h2>Karta</h2>
<form action="/event_card" method="post" enctype="multipart/form-data">
<label for="title">Tytuł:</label><br>
<input type="text" id="title" name="title"><br>
<label for="description">Opis:</label><br>
<input type="text" id="description" name="description"><br>
<label for="begins">Początek:</label>
<input type="datetime-local" id="card_begins" name="begins"><br>
<label for="ends">Koniec:</label>
<input type="datetime-local" id="card_ends" name="ends"><br>
<label for="speaker">Występuje:</label><br>
<input type="text" id="speaker" name="speaker"><br>
<label for="picture">Fotka:</label><br>
<input type="file" id="picture" name="picture" accept="image/png, image/jpeg"><br>
<input type="submit" value="Make it so!">
</form>
</div>
</div>
</div>
</div>
</div>
<footer>
<a href="https://forge.hs3.pl/leming/kronos"
>Źródełka</a
>
</footer>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script type="text/javascript">
// function to get each tab details
const tabs = document.querySelectorAll('[data-tab-value]')
const tabInfos = document.querySelectorAll('[data-tab-info]')
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = document
.querySelector(tab.dataset.tabValue);
tabInfos.forEach(tabInfo => {
tabInfo.classList.remove('active')
})
target.classList.add('active');
})
})
const now = new Date();
const dateValue = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const calendarStartDate = document.getElementById('calendar_begins');
calendarStartDate.value = dateValue;
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
const cardStartDate = document.getElementById('card_begins');
cardStartDate.value = now.toISOString().slice(0,16);
const cardEndsDate = document.getElementById('card_ends');
cardEndsDate.value = now.toISOString().slice(0,16);
</script>
</body>
</html>

View file

@ -1,11 +1,9 @@
import datetime import datetime
import json
import os import os
import sys import sys
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from argparse import ArgumentParser from argparse import ArgumentParser
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from dotenv import load_dotenv from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
@ -13,10 +11,9 @@ from premailer import transform
from .discordbot import KronosBot, intents from .discordbot import KronosBot, intents
from .mobilizon import get_raw_events
MOBILIZON_API_URL = "https://events.hs3.pl/api"
QUERY_LIMIT = 100
TIME_WINDOW_DAYS = 365
TRUNCATE_AFTER_CHARACTERS = 300 TRUNCATE_AFTER_CHARACTERS = 300
TEMPLATES = { TEMPLATES = {
"templates/newsletter_template.html": "newsletter_events.html", "templates/newsletter_template.html": "newsletter_events.html",
@ -31,68 +28,6 @@ def sanitize_html(raw_html: str) -> str:
return cleaned return cleaned
def get_time_window(days: int = 365):
now_utc = datetime.datetime.now(datetime.timezone.utc)
ends_utc = now_utc + datetime.timedelta(days=days)
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
return begins_iso, ends_iso
def build_graphql_query():
return """
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
total
elements {
__typename
... on Event {
id
title
description
beginsOn
endsOn
picture {
url
}
url
physicalAddress {
description
locality
}
}
}
}
}
"""
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
query = build_graphql_query()
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
payload = {"query": query, "variables": variables}
headers = {"Content-Type": "application/json"}
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise RuntimeError(f"GraphQL error: {data['errors']}")
events = []
for elem in data["data"]["searchEvents"]["elements"]:
if elem.get("__typename") == "Event":
begins_iso = elem.get("beginsOn")
if begins_iso:
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
if dt_begins <= dt < dt_ends:
events.append(elem)
return events
def prepare_events_for_template(raw_events: list) -> list: def prepare_events_for_template(raw_events: list) -> list:
days_week = [ days_week = [
"poniedziałek", "poniedziałek",
@ -196,37 +131,6 @@ def inline_css(input_html_path, output_html_path):
f.write(inlined_html) f.write(inlined_html)
def download_picture():
pass
def save_json_content(events):
json_dirname = "src/typst/content"
os.makedirs(json_dirname, exist_ok=True)
with open(os.path.join(json_dirname, "events.json"), "w") as f:
json.dump(sorted(events, key=lambda event: event["beginsOn"]), f)
def extract_picture(event):
picture = event.get("picture")
picture_url = picture.get("url") if picture else None
picture_filename = None
if picture_url:
if "?" in picture_url:
picture_url = picture_url.split("?", 1)[0]
picture_dirname = "src/typst/assets/tmp"
picture_filename = os.path.join(picture_dirname, picture_url.split("/")[-1])
event["picture_filename"] = picture_filename
response = requests.get(picture_url)
os.makedirs(picture_dirname, exist_ok=True)
with open(picture_filename, "wb") as picture_file:
picture_file.write(response.content)
def _main(): def _main():
load_dotenv() load_dotenv()
@ -235,41 +139,7 @@ def _main():
_args = parser.parse_args() _args = parser.parse_args()
try: try:
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS) raw_events = get_raw_events()
print(
f"Fetching events between {begins_on} and {ends_on}...",
file=sys.stderr,
)
raw_events = list(
filter(
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
fetch_events(begins_on, ends_on),
)
)
if not raw_events:
print("No events found in the requested period.", file=sys.stderr)
sys.exit(0)
for ev in raw_events:
print(f"{ev.get('title', 'Untitled')}{ev.get('beginsOn', 'Unknown date')}")
ev["txt_description"] = BeautifulSoup(ev["description"], "html.parser").get_text()
begins = ev["beginsOn"].split("T")
ends = ev["endsOn"].split("T")
txt_date = f"{begins[0]} {begins[1][:-4]}-"
if begins[0] != ends[0]:
txt_date += f"{ends[0]}"
txt_date += f"{ends[1][:-4]}"
ev["txt_date"] = txt_date
extract_picture(ev)
save_json_content(raw_events)
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
events = prepare_events_for_template(raw_events) events = prepare_events_for_template(raw_events)
for template_filename, output_filename in TEMPLATES.items(): for template_filename, output_filename in TEMPLATES.items():

139
src/kronos/mobilizon.py Normal file
View file

@ -0,0 +1,139 @@
import datetime
import json
import os
import sys
import requests
from bs4 import BeautifulSoup
MOBILIZON_API_URL = "https://events.hs3.pl/api"
QUERY_LIMIT = 100
TIME_WINDOW_DAYS = 365
def get_time_window(start_utc=datetime.datetime.now(datetime.timezone.utc), days: int = 365):
now_utc = start_utc
ends_utc = now_utc + datetime.timedelta(days=days)
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
return begins_iso, ends_iso
def build_graphql_query():
return """
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
total
elements {
__typename
... on Event {
id
title
description
beginsOn
endsOn
picture {
url
}
url
physicalAddress {
description
locality
}
}
}
}
}
"""
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
query = build_graphql_query()
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
payload = {"query": query, "variables": variables}
headers = {"Content-Type": "application/json"}
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise RuntimeError(f"GraphQL error: {data['errors']}")
events = []
for elem in data["data"]["searchEvents"]["elements"]:
if elem.get("__typename") == "Event":
begins_iso = elem.get("beginsOn")
if begins_iso:
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
if dt_begins <= dt < dt_ends:
events.append(elem)
return events
def save_json_content(events):
json_dirname = "src/typst/content"
os.makedirs(json_dirname, exist_ok=True)
with open(os.path.join(json_dirname, "events.json"), "w") as f:
json.dump(sorted(events, key=lambda event: event["beginsOn"]), f)
def extract_picture(event):
picture = event.get("picture")
picture_url = picture.get("url") if picture else None
picture_filename = None
if picture_url:
if "?" in picture_url:
picture_url = picture_url.split("?", 1)[0]
picture_dirname = "src/typst/assets/tmp"
picture_filename = os.path.join(picture_dirname, picture_url.split("/")[-1])
event["picture_filename"] = picture_filename
response = requests.get(picture_url)
os.makedirs(picture_dirname, exist_ok=True)
with open(picture_filename, "wb") as picture_file:
picture_file.write(response.content)
def get_raw_events():
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
print(
f"Fetching events between {begins_on} and {ends_on}...",
file=sys.stderr,
)
raw_events = list(
filter(
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
fetch_events(begins_on, ends_on),
)
)
if not raw_events:
print("No events found in the requested period.", file=sys.stderr)
sys.exit(0)
for ev in raw_events:
print(f"{ev.get('title', 'Untitled')}{ev.get('beginsOn', 'Unknown date')}")
ev["txt_description"] = BeautifulSoup(ev["description"], "html.parser").get_text()
begins = ev["beginsOn"].split("T")
ends = ev["endsOn"].split("T")
txt_date = f"{begins[0]} {begins[1][:-4]}-"
if begins[0] != ends[0]:
txt_date += f"{ends[0]}"
txt_date += f"{ends[1][:-4]}"
ev["txt_date"] = txt_date
extract_picture(ev)
save_json_content(raw_events)
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
return raw_events

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View file

@ -17,7 +17,7 @@
dy: 23mm, dy: 23mm,
image( image(
dither( dither(
read("assets/photo.jpg", encoding: none), read(picture, encoding: none),
mode: "bw", mode: "bw",
method: "bayer8x8", method: "bayer8x8",
size: 360, size: 360,
@ -61,11 +61,16 @@
align(right+bottom)[#text(size: 14pt, weight: "bold", name)] align(right+bottom)[#text(size: 14pt, weight: "bold", name)]
} }
#let events = json("content/events.json") #let events = none
#if sys.inputs.len() < 1 {
events = json("content/events.json")
} else {
events = json(bytes(sys.inputs.events))
}
#let index = 0 #let index = 0
#for event in events { #for event in events {
make_event_card(event.title, event.txt_description, event.txt_date, "Gdańsk, al. Wojska Polskiego 41", "", "Joanna Roscoe") make_event_card(event.title, event.txt_description, event.txt_date, "Gdańsk, al. Wojska Polskiego 41", event.picture, event.speaker)
index = index + 1 index = index + 1