gamecult-site/scripts/render-public-infrastructure-dossier.py
2026-08-05 23:55:46 +01:00

457 lines
14 KiB
Python

from __future__ import annotations
import html
import re
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase import pdfmetrics
from reportlab.platypus import (
BaseDocTemplate,
Frame,
KeepTogether,
PageBreak,
PageTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
)
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "GameCult" / "stichting.md"
OUTPUT = ROOT / "docs" / "gamecult_public_digital_infrastructure_dossier.pdf"
INK = colors.HexColor("#10182A")
MUTED = colors.HexColor("#526078")
ORANGE = colors.HexColor("#E06B22")
BLUE = colors.HexColor("#4357D9")
PAPER = colors.HexColor("#F7F5EF")
LINE = colors.HexColor("#D7DCE6")
CALLOUT = colors.HexColor("#EEF1F8")
def register_fonts() -> tuple[str, str, str]:
candidates = [
Path(r"C:\Windows\Fonts\segoeui.ttf"),
Path(r"C:\Windows\Fonts\arial.ttf"),
]
bold_candidates = [
Path(r"C:\Windows\Fonts\segoeuib.ttf"),
Path(r"C:\Windows\Fonts\arialbd.ttf"),
]
italic_candidates = [
Path(r"C:\Windows\Fonts\segoeuii.ttf"),
Path(r"C:\Windows\Fonts\ariali.ttf"),
]
if candidates[0].exists() and bold_candidates[0].exists() and italic_candidates[0].exists():
pdfmetrics.registerFont(TTFont("Dossier", str(candidates[0])))
pdfmetrics.registerFont(TTFont("Dossier-Bold", str(bold_candidates[0])))
pdfmetrics.registerFont(TTFont("Dossier-Italic", str(italic_candidates[0])))
pdfmetrics.registerFontFamily(
"Dossier",
normal="Dossier",
bold="Dossier-Bold",
italic="Dossier-Italic",
boldItalic="Dossier-Bold",
)
return "Dossier", "Dossier-Bold", "Dossier-Italic"
return "Helvetica", "Helvetica-Bold", "Helvetica-Oblique"
FONT, FONT_BOLD, FONT_ITALIC = register_fonts()
def inline_markup(value: str) -> str:
placeholders: list[str] = []
def link(match: re.Match[str]) -> str:
label, url = match.group(1), match.group(2)
token = f"@@LINK{len(placeholders)}@@"
placeholders.append(
f'<a href="{html.escape(url, quote=True)}" color="#{BLUE.hexval()[2:]}">'
f"{html.escape(label)}</a>"
)
return token
value = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link, value)
value = html.escape(value)
value = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", value)
value = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<i>\1</i>", value)
value = re.sub(r"`([^`]+)`", r"<font name=\"Courier\">\1</font>", value)
for index, replacement in enumerate(placeholders):
value = value.replace(f"@@LINK{index}@@", replacement)
return value
def strip_frontmatter(text: str) -> str:
if text.startswith("---\n"):
end = text.find("\n---\n", 4)
if end >= 0:
return text[end + 5 :]
return text
def strip_hero(text: str) -> str:
return re.sub(
r"<section class=\"integrated-dossier-hero\">.*?</section>",
"",
text,
count=1,
flags=re.DOTALL,
)
def parse_blocks(text: str) -> list[tuple[str, object]]:
lines = strip_hero(strip_frontmatter(text)).splitlines()
blocks: list[tuple[str, object]] = []
paragraph: list[str] = []
bullets: list[str] = []
table_rows: list[list[str]] = []
def flush_paragraph() -> None:
if paragraph:
blocks.append(("paragraph", " ".join(part.strip() for part in paragraph)))
paragraph.clear()
def flush_bullets() -> None:
if bullets:
blocks.append(("bullets", list(bullets)))
bullets.clear()
def flush_table() -> None:
if table_rows:
rows = list(table_rows)
if len(rows) > 1 and all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in rows[1]):
rows.pop(1)
blocks.append(("table", rows))
table_rows.clear()
for raw in lines:
line = raw.rstrip()
if not line:
flush_paragraph()
flush_bullets()
flush_table()
continue
if line.startswith("<") and line.endswith(">"):
continue
heading = re.match(r"^(#{1,4})\s+(.+)$", line)
if heading:
flush_paragraph()
flush_bullets()
flush_table()
blocks.append((f"h{len(heading.group(1))}", heading.group(2)))
continue
if line.startswith("> "):
flush_paragraph()
flush_bullets()
flush_table()
blocks.append(("callout", line[2:]))
continue
if re.match(r"^[-*]\s+", line):
flush_paragraph()
flush_table()
bullets.append(re.sub(r"^[-*]\s+", "", line))
continue
if re.match(r"^\d+\.\s+", line):
flush_paragraph()
flush_table()
bullets.append(re.sub(r"^\d+\.\s+", "", line))
continue
if line.startswith("|") and line.endswith("|"):
flush_paragraph()
flush_bullets()
table_rows.append([cell.strip() for cell in line.strip("|").split("|")])
continue
if line == "---":
flush_paragraph()
flush_bullets()
flush_table()
continue
paragraph.append(line)
flush_paragraph()
flush_bullets()
flush_table()
return blocks
def styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"body": ParagraphStyle(
"Body",
parent=base["BodyText"],
fontName=FONT,
fontSize=9.2,
leading=13.1,
textColor=INK,
spaceAfter=3.5 * mm,
),
"h1": ParagraphStyle(
"H1",
parent=base["Heading1"],
fontName=FONT_BOLD,
fontSize=22,
leading=25,
textColor=INK,
spaceBefore=3 * mm,
spaceAfter=5 * mm,
),
"h2": ParagraphStyle(
"H2",
parent=base["Heading2"],
fontName=FONT_BOLD,
fontSize=16,
leading=19,
textColor=INK,
spaceBefore=7 * mm,
spaceAfter=3.5 * mm,
keepWithNext=True,
),
"h3": ParagraphStyle(
"H3",
parent=base["Heading3"],
fontName=FONT_BOLD,
fontSize=11.5,
leading=14,
textColor=BLUE,
spaceBefore=4.5 * mm,
spaceAfter=2 * mm,
keepWithNext=True,
),
"h4": ParagraphStyle(
"H4",
parent=base["Heading4"],
fontName=FONT_BOLD,
fontSize=9.5,
leading=12,
textColor=MUTED,
spaceBefore=3 * mm,
spaceAfter=1.5 * mm,
keepWithNext=True,
),
"bullet": ParagraphStyle(
"Bullet",
parent=base["BodyText"],
fontName=FONT,
fontSize=9,
leading=12.5,
textColor=INK,
leftIndent=5 * mm,
firstLineIndent=-3.5 * mm,
bulletIndent=0,
spaceAfter=1.6 * mm,
),
"callout": ParagraphStyle(
"Callout",
parent=base["BodyText"],
fontName=FONT,
fontSize=9.5,
leading=13.5,
textColor=INK,
leftIndent=5 * mm,
rightIndent=5 * mm,
borderColor=BLUE,
borderWidth=0.8,
borderPadding=4 * mm,
backColor=CALLOUT,
spaceAfter=5 * mm,
),
"table": ParagraphStyle(
"Table",
parent=base["BodyText"],
fontName=FONT,
fontSize=7.6,
leading=10.1,
textColor=INK,
),
"table_head": ParagraphStyle(
"TableHead",
parent=base["BodyText"],
fontName=FONT_BOLD,
fontSize=7.6,
leading=9.5,
textColor=colors.white,
),
}
def footer(canvas, doc) -> None:
canvas.saveState()
canvas.setStrokeColor(LINE)
canvas.line(22 * mm, 15 * mm, 188 * mm, 15 * mm)
canvas.setFont(FONT, 7.5)
canvas.setFillColor(MUTED)
canvas.drawString(22 * mm, 10 * mm, "GameCult public digital infrastructure dossier · discussion draft")
canvas.drawRightString(188 * mm, 10 * mm, str(doc.page))
canvas.restoreState()
def cover_story(st: dict[str, ParagraphStyle]) -> list[object]:
kicker = ParagraphStyle(
"CoverKicker",
parent=st["body"],
fontName=FONT_BOLD,
fontSize=9,
leading=11,
textColor=ORANGE,
alignment=TA_CENTER,
spaceAfter=9 * mm,
)
title = ParagraphStyle(
"CoverTitle",
parent=st["h1"],
fontSize=29,
leading=33,
alignment=TA_CENTER,
textColor=INK,
spaceAfter=8 * mm,
)
subtitle = ParagraphStyle(
"CoverSub",
parent=st["body"],
fontSize=13,
leading=18,
alignment=TA_CENTER,
textColor=MUTED,
spaceAfter=18 * mm,
)
facts = [
["PROPOSITION", "Open semantic infrastructure for durable, inspectable digital institutions"],
["INSTITUTIONAL FORM", "Proposed Dutch stichting with a protected public-interest mission"],
["SUSTAINABILITY", "Public funding plus managed SaaS, integration, training, and consulting"],
["PREPARED", "5 August 2026"],
]
fact_data = [
[
Paragraph(f"<b>{html.escape(label)}</b>", st["table"]),
Paragraph(html.escape(value), st["body"]),
]
for label, value in facts
]
fact_table = Table(fact_data, colWidths=[38 * mm, 100 * mm], hAlign="CENTER")
fact_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
("LEFTPADDING", (0, 0), (-1, -1), 3 * mm),
("RIGHTPADDING", (0, 0), (-1, -1), 3 * mm),
("TOPPADDING", (0, 0), (-1, -1), 3 * mm),
("BOTTOMPADDING", (0, 0), (-1, -1), 3 * mm),
("TEXTCOLOR", (0, 0), (0, -1), ORANGE),
]
)
)
return [
Spacer(1, 24 * mm),
Paragraph("GAMECULT · NETHERLANDS · DISCUSSION DRAFT", kicker),
Paragraph("A proposal for a<br/>sustainable digital common", title),
Paragraph(
"Institutional dossier for Dutch public-sector, research, digital-commons, "
"cultural, and inclusive-employment partners.",
subtitle,
),
fact_table,
Spacer(1, 18 * mm),
Paragraph(
"Candidate public infrastructure seeking institutional validation. "
"No organization named in this dossier has endorsed GameCult.",
ParagraphStyle(
"CoverNote",
parent=st["body"],
fontSize=8.5,
leading=12,
textColor=MUTED,
alignment=TA_CENTER,
),
),
PageBreak(),
]
def table_flowable(rows: list[list[str]], st: dict[str, ParagraphStyle]) -> Table:
width = 166 * mm
columns = len(rows[0])
if columns == 3:
col_widths = [38 * mm, 55 * mm, 73 * mm]
elif columns == 2:
col_widths = [48 * mm, 118 * mm]
else:
col_widths = [width / columns] * columns
cooked = []
for row_index, row in enumerate(rows):
style = st["table_head"] if row_index == 0 else st["table"]
cooked.append([Paragraph(inline_markup(cell), style) for cell in row])
table = Table(cooked, colWidths=col_widths, repeatRows=1, hAlign="LEFT")
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), INK),
("GRID", (0, 0), (-1, -1), 0.35, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 2.2 * mm),
("RIGHTPADDING", (0, 0), (-1, -1), 2.2 * mm),
("TOPPADDING", (0, 0), (-1, -1), 2 * mm),
("BOTTOMPADDING", (0, 0), (-1, -1), 2 * mm),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, PAPER]),
]
)
)
return table
def build_story(text: str) -> list[object]:
st = styles()
story: list[object] = cover_story(st)
for kind, payload in parse_blocks(text):
if kind in {"h1", "h2", "h3", "h4"}:
story.append(Paragraph(inline_markup(str(payload)), st[kind]))
elif kind == "paragraph":
story.append(Paragraph(inline_markup(str(payload)), st["body"]))
elif kind == "callout":
story.append(Paragraph(inline_markup(str(payload)), st["callout"]))
elif kind == "bullets":
items = [
Paragraph(inline_markup(str(item)), st["bullet"], bulletText="-")
for item in payload # type: ignore[union-attr]
]
story.append(KeepTogether(items[:2]) if len(items) == 2 else items[0])
if len(items) != 2:
story.extend(items[1:])
story.append(Spacer(1, 2 * mm))
elif kind == "table":
story.append(table_flowable(payload, st)) # type: ignore[arg-type]
story.append(Spacer(1, 4 * mm))
return story
def render() -> None:
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
doc = BaseDocTemplate(
str(OUTPUT),
pagesize=A4,
leftMargin=22 * mm,
rightMargin=22 * mm,
topMargin=19 * mm,
bottomMargin=21 * mm,
title="GameCult - A proposal for a sustainable digital common",
author="GameCult",
subject="Institutional dossier for Dutch public-sector and digital-commons partners",
)
frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="body")
doc.addPageTemplates([PageTemplate(id="dossier", frames=[frame], onPage=footer)])
doc.build(build_story(SOURCE.read_text(encoding="utf-8")))
print(OUTPUT)
if __name__ == "__main__":
render()