cyber-valley/cve/tools/build_pack.py

#!/usr/bin/env python3
"""Signable pack — the hak sewa instruments as books on the Company's
letterhead: a cover, contents, numbered parts, Bahasa Indonesia beside
English in two columns, footnotes at the foot of every page, a colophon
with the source hash and a QR to the living page. No personal data —
every party field is a blank to fill in by hand.

    tools/.venv/bin/python tools/build_pack.py
    … -o "legal/10 hak sewa/signable pack" --public /tmp/cv-main

Sources: the bilingual template and akta in this repo; the annexes and the
notary's long-form template from the public cyber-valley graph (a checkout
of origin/main, `--public`).
"""
import argparse
import datetime
import hashlib
import html
import importlib.util
import io
import os
import pathlib
import re
import tempfile

HERE = pathlib.Path(__file__).resolve().parent
CVE = HERE.parent
spec = importlib.util.spec_from_file_location("md2pdf", HERE / "md2pdf.py")
M = importlib.util.module_from_spec(spec)
spec.loader.exec_module(M)

M.BASE = "https://cyberia.blog"
STAMP = datetime.date.today().strftime("%d.%m.%Y")
ZWSP = "​"

# code · short title · subtitle · source · the living page
DOCS = [
    ("HS-00", "Hak Sewa", "Paket penandatanganan · Signable pack", None, "https://cyberia.blog/cyber-valley/products/leasehold/"),
    ("HS-01", "Hak Sewa", "Perjanjian · Lease Agreement", CVE / "legal/10 hak sewa/hak sewa template.bilingual.md", "https://cyberia.blog/cyber-valley/products/leasehold/hak-sewa-template/"),
    ("HS-02", "Akta Sewa", "Bentuk Sederhana · Simple Form", CVE / "legal/10 hak sewa/hak sewa simple.bilingual.md", "https://cyberia.blog/cyber-valley/products/leasehold/hak-sewa-simple/"),
    ("HS-03", "Perjanjian Sewa Menyewa", "Template Notaris", "products/leasehold/perjanjian sewa menyewa template.md", "https://cyberia.blog/cyber-valley/products/leasehold/perjanjian-sewa-menyewa-template/"),
    ("HS-A", "Annex A", "Estate Registry · Daftar Alas Hak", "legal/annexes/annex a estate registry.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-a-estate-registry/"),
    ("HS-B", "Annex B", "Plot Boundary · Batas Bidang", "legal/annexes/annex b plot boundary.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-b-plot-boundary/"),
    ("HS-C", "Annex C", "Due Diligence Pack · Paket Uji Tuntas", "legal/annexes/annex c due diligence pack.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-c-due-diligence-pack/"),
    ("HS-D", "Annex D", "Offer of Record · Penawaran Tercatat", "legal/annexes/annex d offer of record.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-d-offer-of-record/"),
    ("HS-E", "Annex E", "Century Index", "legal/annexes/annex e century index.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-e-century-index/"),
    ("HS-F", "Annex F", "Utilities, Access and Mobility · Utilitas, Akses dan Mobilitas", "legal/annexes/annex f utilities access mobility.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-f-utilities-access-mobility/"),
    ("HS-G", "Annex G", "Handover Condition · Kondisi Serah Terima", "legal/annexes/annex g handover condition.md", "https://cyberia.blog/cyber-valley/legal/annexes/annex-g-handover-condition/"),
]
FILES = {
    "HS-01": "01 hak sewa agreement", "HS-02": "02 akta sewa simple", "HS-03": "03 perjanjian sewa menyewa",
    "HS-A": "04 annex a estate registry", "HS-B": "05 annex b plot boundary", "HS-C": "06 annex c due diligence pack",
    "HS-D": "07 annex d offer of record", "HS-E": "08 annex e century index", "HS-F": "09 annex f utilities access mobility",
    "HS-G": "10 annex g handover condition",
}


# ---------------------------------------------------------------- blanks
def blank(chars):
    """A bracketed rule that wraps every three strokes and fills the line."""
    return "[" + ZWSP.join(["___"] * max(1, round(chars / 3))) + "]"


def widen(t):
    for old, n in (("[___]", 27), ("[email]", 33), ("[tanggal]", 27), ("[date]", 27)):
        t = t.replace(old, blank(n))
    return t


# ---------------------------------------------------------------- links
PUBLIC = {}  # normalised page name → cyberia.blog URL
TITLES = {}  # normalised page name → the page's H1


def index_public(root):
    for base, _, files in os.walk(root):
        for f in files:
            if not f.endswith(".md"):
                continue
            rel = os.path.relpath(os.path.join(base, f), root)
            web = rel[:-3]
            if web.endswith("README"):
                web = os.path.dirname(web)
            url = f"{M.BASE}/cyber-valley/" + web.lower().replace(" ", "-") + "/"
            keys = [f[:-3], web]
            title = f[:-3]
            try:
                head = open(os.path.join(base, f), encoding="utf-8").read(2500)
                m = re.search(r"^alias:\s*(.+)$", head, re.M)
                if m:
                    keys += [a.strip() for a in m.group(1).split(",")]
                t = re.search(r"^#\s+(.+)$", head, re.M)
                if t:
                    title = re.sub(r"\[\[([^\]|]+)\|([^\]]+)\]\]", r"\2", t.group(1)).replace("span>", "").strip()
            except OSError:
                pass
            for k in keys:
                PUBLIC.setdefault(k.lower().replace("-", " "), url)
                TITLES.setdefault(k.lower().replace("-", " "), title)


def norm(page):
    key = page.strip("/").lower().replace("-", " ")
    return key[len("cyber valley/"):] if key.startswith("cyber valley/") else key


def link_of(target, source, wiki):
    """A public page's address, or None for a page of the private repo."""
    target = target.rstrip("\\")
    if target.startswith("http"):
        return target
    page, _, anchor = target.partition("#")
    frag = ("#" + anchor) if anchor else ""
    if not page:
        return None
    key = norm(page)
    for k in (key, key.rsplit("/", 1)[-1]):
        if k in PUBLIC:
            return PUBLIC[k] + frag
    if key.startswith(("cyberia/", "cyber valley/policies", "cyber valley/strategy", "cyber valley/products")):
        return f"{M.BASE}/" + page.strip("/").lower().replace(" ", "-") + "/" + frag
    return None


_where = M.where


def where(target, source=None):
    target = target.rstrip("\\")
    page, _, anchor = target.partition("#")
    key = norm(page)
    for k in (key, key.rsplit("/", 1)[-1]):
        if page and k in TITLES:
            return TITLES[k] + (f", {M.words(anchor)}" if anchor else "")
    return _where(target, source)


M.link_of = link_of
M.where = where

FN = {"seen": set(), "n": 0}


def fn_note(text, target, wiki=True):
    target = target.rstrip("\\")
    url = M.link_of(target, M.SOURCE[0], wiki)
    label = M.where(target, M.SOURCE[0])
    key = url or label
    if key in FN["seen"]:
        return text
    FN["seen"].add(key)
    FN["n"] += 1
    link = f' · <a href="{html.escape(url)}">{html.escape(url.replace("https://", ""))}</a>' if url else ""
    return f'{text}<span class="fn" id="fn-{FN["n"]}" data-n="0" data-k="{html.escape(key)}">{html.escape(label)}{link}</span>'


M.note = fn_note


# ---------------------------------------------------------------- blocks
def blocks(md):
    md = re.sub(r"^---\n.*?\n---\n", "", md.strip("\n"), flags=re.S)
    md = re.sub(r"\n(#{1,6} [^\n]*)\n", r"\n\n\1\n\n", md)
    return [b for b in re.split(r"\n\s*\n", md) if b.strip()]


def kind(b):
    first = b.lstrip("\n").split("\n", 1)[0]
    if re.match(r"^#{1,6}\s", first):
        return "h"
    if first.lstrip().startswith("|"):
        return "table"
    if first.startswith("    "):
        return "code"
    if first.strip() in ("---", "***"):
        return "hr"
    return "text"


BI = {("bahasa indonesia", "english")}
BI3 = {("bidang / field", "bahasa indonesia", "english"), ("istilah / term", "makna (bahasa indonesia)", "meaning (english)")}


def polish(fragment):
    """Tables: the bilingual body reads as two text columns, the rest as thin rules."""
    fragment = re.sub(r"<section class=\"keep\">|</section>", "", fragment)
    fragment = re.sub(r"<em>([^<]*)</em>", lambda m: " ".join(f'<span class="it">{w}</span>' for w in m.group(1).split(" ") if w), fragment)

    def tab(m):
        t = m.group(0)
        head = tuple(re.sub(r"<[^>]+>", "", h).strip().lower() for h in re.findall(r"<th>([\s\S]*?)</th>", t))
        cols = len(re.findall(r"<t[hd]\b", t.split("</tr>", 1)[0]))
        cls = f"c{min(cols, 9)}"
        if head in BI:
            cls += " bi"
        elif head in BI3:
            cls += " bi3"
        elif cols >= 7:
            cls += " wide"
        return t.replace("<table>", f'<table class="{cls}">', 1)

    return re.sub(r"<table>[\s\S]*?</table>", tab, fragment)


HEAD = re.compile(r"^(?:(?:(Bagian|Daftar|Pasal|Part|Schedule|Annex|Lampiran)\s+)?([0-9]+|[A-Z][0-9]+)\.?\s+)?(.*?)(?:\s+—\s+(?:Part|Schedule)\s+[0-9A-Z]+\.?\s*(.*))?$")


def heading(text, n):
    """`Bagian 3. Imbalan — Part 3. Consideration` → number, two titles."""
    plain = re.sub(r"\[\[([^\]|]+)\|([^\]]+)\]\]", r"\2", text).replace("span>", "").replace("**", "")
    m = HEAD.match(plain.strip())
    if m and m.group(2):
        num, a, b = m.group(2), m.group(3), m.group(4)
    else:
        num, a, b = "", plain, None
        if " — " in plain:
            a, b = plain.split(" — ", 1)
    num = num.zfill(2) if num.isdigit() else num
    a = re.sub(r"^[—–-]\s*", "", a.strip())
    inner = f'<span class="t"><span class="a">{html.escape(a.strip())}</span>' + (f'<span class="b">{html.escape(b.strip())}</span>' if b else "") + "</span>"
    mark = f'<span class="n">{html.escape(num)}</span>' if num else '<span class="n tri">△</span>'
    return f'<h2 id="s{n}">{mark}{inner}</h2>', (num, a.strip(), (b or "").strip())


def render(md):
    if kind(md) == "code":
        lines = [l[4:] if l.startswith("    ") else l for l in md.split("\n")]
        return '<pre class="code">' + html.escape("\n".join(lines)) + "</pre>"
    if kind(md) == "hr":
        return "<hr>"
    return polish(M.convert(md))


def document(code, title, sub, source):
    """The body of one instrument, its table of contents and the source hash."""
    md = widen(pathlib.Path(source).read_text(encoding="utf-8"))
    M.SOURCE[0] = str(pathlib.Path(source).resolve())
    FN["seen"] = set()
    toc, parts, n = [], [], 0
    raw = []
    for b in blocks(md):
        if kind(b) == "h" and b.startswith("## "):
            n += 1
            h, meta = heading(b[3:].strip(), n)
            toc.append((f"{code}-s{n}", meta))
            raw.append((h.replace(f'id="s{n}"', f'id="{code}-s{n}"'), b))
        elif kind(b) == "h" and b.startswith("# "):
            continue  # the cover carries the title
        else:
            raw.append((render(b), b))
    i = 0
    while i < len(raw):
        h, b = raw[i]
        if kind(b) == "h" and i + 1 < len(raw):
            nh, nb = raw[i + 1]
            big = (kind(nb) in ("table", "code") and nb.count("\n") > 22) or (kind(nb) not in ("table", "code") and len(nb) > 2600)
            if not big:
                parts.append(f'<div class="keep">{h}{nh}</div>')
                i += 2
                continue
        parts.append(h)
        i += 1
    sha = hashlib.sha256(pathlib.Path(source).read_bytes()).hexdigest()
    return "\n".join(parts), toc, sha


# ---------------------------------------------------------------- brand
def tri(color):
    return f'<svg class="tri" viewBox="0 0 100 100"><polygon points="50,12 88,82 12,82" fill="none" stroke="{color}" stroke-width="9"/></svg>'


def dots(color):
    return ('<svg class="dots" viewBox="15.5 15 69 68">' + "".join(
        f'<circle cx="{x}" cy="{y}" r="10" fill="{color}"/>' for x, y in
        [(50, 26.25), (68.57, 35.19), (73.15, 55.28), (60.30, 71.40), (39.70, 71.40), (26.85, 55.28), (31.43, 35.19)]) + "</svg>")


def cv(color="#000"):
    return f'<span class="cvw" style="color:{color}">{tri(color)}<b>cyber</b> valley</span>'


def cy(color="#000"):
    return f'<span class="cyw" style="color:{color}">{dots(color)}<b>cyber•ia</b></span>'


def qr_svg(url, color):
    import qrcode
    import qrcode.image.svg
    img = qrcode.make(url, image_factory=qrcode.image.svg.SvgPathImage, box_size=10, border=0)
    buf = io.BytesIO()
    img.save(buf)
    s = buf.getvalue().decode()
    s = re.sub(r"<\?xml[^>]*\?>", "", s)
    s = s.replace("<path", f'<path fill="{color}"', 1)
    return s


GRID = ('<svg class="grid" viewBox="0 0 210 297" preserveAspectRatio="none">'
        + "".join(f'<line x1="{x}" y1="0" x2="{x}" y2="297"/>' for x in range(0, 211, 8))
        + "".join(f'<line x1="0" y1="{y}" x2="210" y2="{y}"/>' for y in range(0, 298, 8))
        + "</svg>")


def cover(code, title, sub, live, kind_line):
    """White, like every page: the landowner's mark above, the document in the middle, cyberia below."""
    return f"""<div class="cover">{GRID}
<div class="cover-top">{cv('#000')}<span class="co">PT CYBER VALLEY ESTATE</span></div>
<div class="cover-mid">
  <div class="docid">{html.escape(code)} · {html.escape(kind_line)}</div>
  <h1>{html.escape(title)}</h1>
  <div class="sub">{html.escape(sub)}</div>
  <div class="meta">Bahasa Indonesia · English — naskah Indonesia yang berlaku · the Indonesian text prevails</div>
</div>
<div class="cover-bottom">
  <div class="qr">{qr_svg(live, '#000')}<span>living page<br>{html.escape(live.replace('https://', ''))}<br>{html.escape(STAMP)}</span></div>
  <div class="brand"><span class="pp">a cyberia project</span>{cy('#000')}</div>
</div></div>"""


def contents(toc, title="Daftar Isi · Contents"):
    rows = "".join(
        f'<a class="toc-row" href="#{sid}"><span class="n">{html.escape(num)}</span><span class="t">{html.escape(a)}'
        + (f'<span class="b">{html.escape(b)}</span>' if b else "") + f'</span><span class="p" href="#{sid}"></span></a>'
        for sid, (num, a, b) in toc)
    return f'<div class="toc"><h2 class="toc-h"><span class="n tri">△</span><span class="t"><span class="a">{title}</span></span></h2>{rows}</div>'


def colophon(code, title, sub, live, sha, source):
    rel = os.path.relpath(str(source), str(CVE.parent)) if source else "the ten instruments above"
    return f"""<div class="colophon">
<h2 class="toc-h"><span class="n tri">△</span><span class="t"><span class="a">Kolofon · Colophon</span></span></h2>
<table class="c2 meta"><tbody>
<tr><td>document</td><td>{html.escape(code)} · {html.escape(title)} — {html.escape(sub)}</td></tr>
<tr><td>edition</td><td>template · Bahasa Indonesia beside English · the Indonesian text prevails (UU 24/2009 Pasal 31)</td></tr>
<tr><td>living page</td><td>{html.escape(live)}</td></tr>
<tr><td>source</td><td>{html.escape(rel)}</td></tr>
<tr><td>source sha256</td><td class="mono">{sha}</td></tr>
<tr><td>built</td><td>{html.escape(STAMP)} · cve/tools/build_pack.py · WeasyPrint · Play</td></tr>
<tr><td>landowner</td><td>PT CYBER VALLEY ESTATE · PT PMA · NIB 2504220016017 · NPWP 53.898.326.3-902.000 · Gesing, Banjar, Buleleng, Bali 81152</td></tr>
</tbody></table>
<div class="colo-qr">{qr_svg(live, '#000')}<span>scan for the living page — the version of the day binds; a lease keeps the version it was built under</span></div>
<div class="parent"><span class="pp">a cyberia project</span>{cy('#000')}</div>
</div>"""


CSS = """
@font-face { font-family: Play; font-weight: 400; src: url("{reg}"); }
@font-face { font-family: Play; font-weight: 700; src: url("{bold}"); }
@page {
  size: A4; margin: 24mm 18mm 20mm 18mm;
  counter-reset: footnote;
  @top-left { content: element(runhead); vertical-align: bottom; padding-bottom: 2.5mm; width: 70%; }
  @top-right { content: element(runmark); vertical-align: bottom; padding-bottom: 2.5mm; width: 30%; text-align: right; }
  @footnote { border-top: .5pt solid #000; padding-top: 2.5pt; margin-top: 4pt; }
  @bottom-left { content: "PT CYBER VALLEY ESTATE · {footer} · {stamp}"; font: 7pt Play, sans-serif; color: #000; letter-spacing: .3pt; }
  @bottom-right { content: counter(page) " / " counter(pages); font: 7pt Play, sans-serif; color: #000; }
}
@page cover { margin: 0; @top-left { content: none; } @top-right { content: none; } @bottom-left { content: none; } @bottom-right { content: none; } }
@page plain { @top-left { content: none; } @top-right { content: none; } }
.cover { page: cover; position: relative; width: 210mm; height: 297mm; color: #000; box-sizing: border-box; padding: 18mm 18mm 16mm; display: flex; flex-direction: column; justify-content: space-between; break-after: page; }
.cover .grid { position: absolute; left: 0; top: 0; width: 210mm; height: 297mm; }
.cover .grid line { stroke: #000; stroke-opacity: .07; stroke-width: .25; }
.cover-top { display: flex; justify-content: space-between; align-items: baseline; }
.cover-top .co { font-size: 8pt; font-weight: 700; letter-spacing: 2pt; }
.cover-mid .docid { font-size: 8.5pt; letter-spacing: 2.5pt; text-transform: uppercase; margin-bottom: 10mm; opacity: .8; }
.cover-mid h1 { font-size: 44pt; line-height: 1; margin: 0 0 5mm; font-weight: 700; letter-spacing: -.5pt; color: #000; }
.cover-mid .sub { font-size: 17pt; font-weight: 400; margin-bottom: 8mm; }
.cover-mid .meta { font-size: 8.5pt; opacity: .8; max-width: 120mm; line-height: 1.5; }
.cover-bottom { display: flex; justify-content: space-between; align-items: flex-end; }
.cover-bottom .qr { display: flex; align-items: flex-end; gap: 4mm; font-size: 7pt; line-height: 1.5; opacity: .9; }
.cover-bottom .qr svg { width: 22mm; height: 22mm; }
.cover-bottom .brand { display: flex; align-items: center; gap: 4mm; } .cover-bottom .brand .pp { font-size: 8pt; font-weight: 700; letter-spacing: 1.5pt; text-transform: uppercase; }
.cover .cvw { font-size: 22pt; } .cover .cyw { font-size: 22pt; }
.runhead { position: running(runhead); font-size: 7pt; letter-spacing: 1.2pt; text-transform: uppercase; color: #000; border-bottom: .5pt solid #000; padding-bottom: 1.5mm; }
.runmark { position: running(runmark); text-align: right; }
.it { display: inline-block; transform: skewX(-11deg); }
.cvw, .cyw { font-family: Play; white-space: nowrap; }
.cvw { font-size: 10.5pt; } .cvw b { font-weight: 700; }
.cyw { font-size: 9.5pt; font-weight: 700; letter-spacing: .4pt; }
.cvw .tri { width: 1em; height: 1em; vertical-align: -.1em; margin-right: .3em; }
.cyw .dots { width: 1.1em; height: 1.1em; vertical-align: -.2em; margin-right: .3em; }
.fn { float: footnote; font-size: 6.8pt; line-height: 1.3; font-weight: 400; font-style: normal; letter-spacing: 0; text-align: left; hyphens: manual; }
.fn a { text-decoration: underline; overflow-wrap: anywhere; }
.fn::footnote-call { content: attr(data-n); vertical-align: super; font-size: 6pt; line-height: 0; padding-left: .5pt; }
.fn::footnote-marker { content: attr(data-n) "  "; font-weight: 700; }
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: Play, sans-serif; font-size: 9.4pt; line-height: 1.46; color: #000; margin: 0; }
a { color: #000; text-decoration: none; }
p { margin: 0 0 5pt; text-align: justify; hyphens: auto; orphans: 3; widows: 3; }
li { margin-bottom: 2pt; hyphens: auto; }
ul, ol { margin: 0 0 5pt; padding-left: 14pt; }
code, .mono { font-family: Play, monospace; letter-spacing: .3pt; overflow-wrap: anywhere; }
pre, .code { font-family: Play, monospace; white-space: pre-wrap; font-size: 8.8pt; margin: 3pt 0 6pt; padding: 5pt 8pt; border-left: 1.5pt solid #000; background: #f4f4f4; }
sup.ref { font-size: 6.5pt; line-height: 0; vertical-align: super; padding-left: .5pt; }
blockquote { margin: 0 0 5pt; padding: 0 0 0 6pt; border-left: 1.5pt solid #000; }
blockquote p { margin: 0 0 3pt; }
strong { font-weight: 700; }
h2 { display: flex; align-items: baseline; gap: 5mm; margin: 14pt 0 7pt; padding: 8pt 0 4pt; border-top: 1.2pt solid #000; break-after: avoid; page-break-after: avoid; }
h2 .n { font-size: 24pt; font-weight: 700; line-height: 1; min-width: 12mm; letter-spacing: -.5pt; }
h2 .n.tri { font-size: 14pt; }
h2 .t { display: flex; flex-direction: column; gap: 1pt; }
h2 .a { font-size: 12.5pt; font-weight: 700; }
h2 .b { font-size: 10pt; font-weight: 400; letter-spacing: .3pt; }
h3 { font-size: 10pt; margin: 9pt 0 4pt; break-after: avoid; }
.keep { break-inside: avoid; page-break-inside: avoid; }
table { width: 100%; border-collapse: collapse; margin: 3pt 0 6pt; font-size: 8.6pt; line-height: 1.38; table-layout: fixed; }
thead { display: table-header-group; }
tr { break-inside: avoid; page-break-inside: avoid; }
th, td { border: .4pt solid #000; padding: 3pt 4.5pt; vertical-align: top; text-align: left; hyphens: auto; overflow-wrap: break-word; }
th { background: #eee; font-weight: 700; hyphens: manual; font-size: 7.5pt; letter-spacing: .6pt; text-transform: uppercase; }
table.c2 td:first-child, table.c2 th:first-child { width: 50%; }
table.c3 td:first-child, table.c3 th:first-child { width: 18%; }
table.wide { font-size: 7.2pt; }
table.wide th, table.wide td { padding: 2pt 2.6pt; }
/* the bilingual body: two columns of text, a hairline between, no grid */
table.bi th, table.bi3 th { background: none; border: none; border-bottom: .8pt solid #000; padding: 0 4.5pt 2pt; }
table.bi td { border: none; border-bottom: .3pt solid #c8c8c8; padding: 5pt 4.5pt 6pt; }
table.bi td:first-child { border-right: .5pt solid #000; }
table.bi3 td { border: none; border-bottom: .3pt solid #c8c8c8; padding: 4pt 4.5pt; }
table.bi3 td:first-child { font-size: 7.4pt; letter-spacing: .5pt; text-transform: uppercase; font-weight: 700; color: #000; width: 20%; }
table.bi3 td:nth-child(2) { border-right: .5pt solid #000; }
table.meta td:first-child { width: 24%; font-size: 7.4pt; letter-spacing: .6pt; text-transform: uppercase; font-weight: 700; }
hr { border: 0; border-top: .5pt solid #000; margin: 8pt 0; }
.doc { break-before: page; }
.toc { break-after: page; }
.toc-h { border-top: none; margin-top: 0; }
.toc-row { display: flex; align-items: baseline; gap: 3mm; padding: 2pt 0; border-bottom: .3pt dotted #999; font-size: 9pt; line-height: 1.35; }
.toc-row .n { min-width: 9mm; font-weight: 700; }
.toc-row .t { flex: 1; display: flex; gap: 2mm; align-items: baseline; flex-wrap: wrap; }
.toc-row .b { font-size: 8pt; color: #444; }
.toc-row .p { margin-left: auto; font-weight: 700; } .toc-row .p::after { content: target-counter(attr(href), page); }
.colophon { break-before: page; page: plain; }
.colo-qr { display: flex; align-items: center; gap: 5mm; margin: 8mm 0; font-size: 8pt; max-width: 120mm; line-height: 1.5; }
.colo-qr svg { width: 24mm; height: 24mm; flex: none; }
.parent { margin-top: 10mm; display: flex; align-items: center; gap: 4mm; }
.parent .pp { font-size: 8pt; font-weight: 700; letter-spacing: 1.5pt; text-transform: uppercase; }
"""


def css_for(footer):
    return (CSS.replace("{reg}", (HERE / "brand" / "Play-Regular.ttf").as_uri())
            .replace("{bold}", (HERE / "brand" / "Play-Bold.ttf").as_uri())
            .replace("{footer}", footer.replace('"', "'"))
            .replace("{stamp}", STAMP))


def render_pdf(page, tmp, out):
    """Two passes: lay out, find each footnote's page, number notes from 1 on every page."""
    from weasyprint import HTML
    for attempt in range(3):
        doc = HTML(string=page, base_url=str(tmp)).render()
        where_ = {}
        for pi, p in enumerate(doc.pages):
            stack = [p._page_box]
            while stack:
                b = stack.pop()
                el = getattr(b, "element", None)
                if el is not None:
                    i = el.get("id") or ""
                    if i.startswith("fn-"):
                        where_.setdefault(i, pi)
                stack.extend(getattr(b, "children", []) or [])
        per, nums, first = {}, {}, {}
        keys = {m.group(1): m.group(2) for m in re.finditer(r'id="(fn-\d+)" data-n="\d+" data-k="([^"]*)"', page)}
        pos = {m.group(1): m.start() for m in re.finditer(r'id="(fn-\d+)"', page)}
        dup = set()
        for i in sorted(where_, key=lambda k: pos.get(k, 0)):
            pk = (where_[i], keys.get(i))
            if pk in first:
                nums[i] = nums[first[pk]]
                dup.add(i)
                continue
            first[pk] = i
            per[where_[i]] = per.get(where_[i], 0) + 1
            nums[i] = per[where_[i]]

        def swap(m):
            i = m.group(1)
            if i in dup:
                return f'<sup class="ref">{nums[i]}</sup>'
            return m.group(0).replace('data-n="0"', f'data-n="{nums.get(i, 0)}"', 1)

        new = re.sub(r'<span class="fn" id="(fn-\d+)" data-n="\d+"[^>]*>[\s\S]*?</span>', swap, page) if attempt == 0 else page
        new = re.sub(r'id="(fn-\d+)" data-n="\d+"', lambda m: f'id="{m.group(1)}" data-n="{nums.get(m.group(1), 0)}"', new)
        if new == page:
            break
        page = new
    doc.write_pdf(str(out))
    return len(doc.pages)


def html_page(title, css, body, runhead):
    return (f"<!doctype html><html lang='id'><meta charset='utf-8'><title>{html.escape(title)}</title>"
            f"<style>{css}</style><body><div class='runhead'>{html.escape(runhead)}</div><div class='runmark'>{cy()}</div>{body}</body></html>")


def main():
    ap = argparse.ArgumentParser(description="the signable hak sewa pack as books on the letterhead")
    ap.add_argument("-o", "--out", default=str(CVE / "legal/10 hak sewa/signable pack"))
    ap.add_argument("--public", default="/tmp/cv-main", help="checkout of cyber-valley origin/main")
    a = ap.parse_args()
    out = pathlib.Path(a.out)
    out.mkdir(parents=True, exist_ok=True)
    tmp = pathlib.Path(tempfile.mkdtemp(prefix="pack-"))
    M.build_index(str(CVE / "legal"))
    M.build_index(str(CVE))
    M.index_graph()
    index_public(a.public)

    pack_body, pack_toc = [], []
    for code, title, sub, src, live in DOCS:
        if src is None:
            continue
        path = src if isinstance(src, pathlib.Path) else pathlib.Path(a.public) / src
        FN["n"] = 0
        body, toc, sha = document(code, title, sub, path)
        long = len(toc) >= 6
        head = f"{title} — {sub}"
        one = (cover(code, title, sub, live, "template")
               + (contents(toc) if long else "")
               + f'<div class="body">{body}</div>'
               + colophon(code, title, sub, live, sha, path))
        pdf = out / f"{FILES[code]}.pdf"
        pages = render_pdf(html_page(head, css_for(head), one, head), tmp, pdf)
        print(f"{code} {FILES[code]}: {pages} pages")
        pack_toc.append((code, title, sub))
        pack_body.append(f'<div class="doc" id="d{code}">' + cover(code, title, sub, live, "template")
                         + (contents(toc) if long else "")
                         + f'<div class="body">{body}</div></div>')

    code, title, sub, _, live = DOCS[0]
    FN["n"] = 0
    toc_rows = "".join(
        f'<a class="toc-row" href="#d{c}"><span class="n">{html.escape(c)}</span><span class="t">{html.escape(t)}<span class="b">{html.escape(s)}</span></span><span class="p" href="#d{c}"></span></a>'
        for c, t, s in pack_toc)
    pack_html = (cover(code, title, sub, live, "the whole pack")
                 + f'<div class="toc"><h2 class="toc-h"><span class="n tri">△</span><span class="t"><span class="a">Daftar Isi · Contents</span></span></h2>{toc_rows}</div>'
                 + "".join(pack_body)
                 + colophon(code, title, sub, live, hashlib.sha256("".join(pack_body).encode()).hexdigest(), None))
    pack = out / "hak sewa — signable pack ID-EN.pdf"
    pages = render_pdf(html_page("Hak Sewa — signable pack", css_for("Hak Sewa — signable pack"), pack_html, "Hak Sewa — signable pack"), tmp, pack)
    print(f"{code} pack: {pages} pages")


if __name__ == "__main__":
    main()

Graph