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 = ""
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",
}
def blank(chars):
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
PUBLIC = {} TITLES = {}
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):
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
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):
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):
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):
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 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
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):
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):
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()