import argparse
import datetime
import html
import os
import pathlib
import re
import subprocess
import sys
import tempfile
PAGES = {} TITLES = {}
def slugify(text):
t = re.sub(r'\*\*|__|`|\[\[|\]\]', '', text)
t = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', t)
t = t.split('|')[-1] if '|' in t else t
t = re.sub(r'[^0-9a-zA-Z\s-]', '', t.lower())
return re.sub(r'-+', '-', re.sub(r'\s+', '-', t.strip())).strip('-')
def build_index(root):
for base, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if not d.startswith('.')]
for f in files:
if f.endswith('.md'):
path = os.path.join(base, f)
PAGES.setdefault(f[:-3].lower().replace('-', ' '), path)
def headings(path):
if path not in TITLES:
title, anchors = os.path.basename(path)[:-3], {}
try:
first = True
for line in open(path, encoding='utf-8'):
m = re.match(r'^(#{1,6})\s+(.*?)\s*$', line)
if not m:
continue
text = re.sub(r'\[\[([^\]|]+)\|([^\]]+)\]\]', r'\2', m.group(2))
text = re.sub(r'\*\*|\[\[|\]\]', '', text)
text = re.sub(r'[\u25b2\u26a0\ufe0f]', '', text).strip()
if first and len(m.group(1)) == 1:
title, first = text, False
anchors[slugify(text)] = text
except OSError:
pass
TITLES[path] = (title, anchors)
return TITLES[path]
def resolve(name):
return PAGES.get(name.lower().replace('-', ' '))
REFS = [] REF_INDEX = {}
def words(slug):
t = slug.replace('-', ' ').replace('_', ' ').strip()
m = re.match(r'^(\d+[a-z]?)\s+(.*)$', t)
if m:
return "\u00a7" + m.group(1) + " " + m.group(2)
return t
def where(url, source=None):
if url.startswith('http'):
return url
page_part, _, anchor = url.partition('#')
page_part = page_part.strip('/')
if page_part.startswith('cyber-valley/cve/'):
page_part = page_part[len('cyber-valley/cve/'):]
path = resolve(page_part.rsplit('/', 1)[-1]) if page_part else source
title, anchors = headings(path) if path else (words(page_part.rsplit('/', 1)[-1]), {})
section = anchors.get(anchor) or (words(anchor) if anchor else '')
if not page_part:
return f"this document, {section}" if section else "this document"
return f"{title}, {section}" if section else title
SOURCE = [None]
def ref(target):
key = where(target, SOURCE[0])
if key not in REF_INDEX:
REFS.append(key)
REF_INDEX[key] = len(REFS)
return REF_INDEX[key]
def note(text, target):
return f'{text}<sup class="ref">{ref(target)}</sup>'
def inline(t):
t = html.escape(t)
t = re.sub(r'\[\[([^\]|]+)\|([^\]]+)\]\]', lambda m: note(m.group(2), m.group(1)), t)
t = re.sub(r'\[\[([^\]]+)\]\]', lambda m: note(m.group(1), m.group(1)), t)
t = re.sub(r'\[([^\]]+)\]\((#[^)]*|/[^)]*)\)', lambda m: note(m.group(1), m.group(2)), t)
t = re.sub(r'\[([^\]]+)\]\((https?://[^)]+)\)', lambda m: note(m.group(1), m.group(2)), t)
t = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', t)
t = re.sub(r'(?<!\*)\*([^*\n]+)\*(?!\*)', r'<em>\1</em>', t)
t = re.sub(r'`([^`]+)`', r'<code>\1</code>', t)
return t
def row(line):
s = line.strip()
if s.startswith('|'):
s = s[1:]
if s.endswith('|') and not s.endswith('\\|'):
s = s[:-1]
return [c.strip().replace('\\|', '|') for c in re.split(r'(?<!\\)\|', s)]
def convert(md):
md = re.sub(r'^---\n.*?\n---\n', '', md, flags=re.S) out, lines, i = [], md.split('\n'), 0
while i < len(lines):
L = lines[i]
if re.match(r'^\s*\|', L):
has_header = i+1 < len(lines) and re.match(r'^\s*\|[\s:|-]+\|?\s*$', lines[i+1])
head = row(L) if has_header else None
i += 2 if has_header else 1
body = [] if has_header else [row(L)]
while i < len(lines) and re.match(r'^\s*\|', lines[i]):
body.append(row(lines[i])); i += 1
out.append('<table>')
if head:
out.append('<thead><tr>' + ''.join(f'<th>{inline(c)}</th>' for c in head) + '</tr></thead>')
out.append('<tbody>')
for r in body:
out.append('<tr>' + ''.join(f'<td>{inline(c)}</td>' for c in r) + '</tr>')
out.append('</tbody></table>')
continue
m = re.match(r'^(#{1,6})\s+(.*)$', L)
if m:
n = len(m.group(1)); out.append(f'<h{n}>{inline(m.group(2))}</h{n}>'); i += 1; continue
if re.match(r'^\s*(---|___|\*\*\*)\s*$', L):
out.append('<hr>'); i += 1; continue
if L.startswith('>'):
buf = []
while i < len(lines) and lines[i].startswith('>'):
buf.append(lines[i].lstrip('>').strip()); i += 1
out.append('<blockquote>' + ''.join(
f'<p>{inline(p)}</p>' for p in '\n'.join(buf).split('\n\n') if p.strip()) + '</blockquote>')
continue
m = re.match(r'^\s*([-*+]|\d+\.)\s+(.*)$', L)
if m:
ordered = bool(re.match(r'^\s*\d+\.', L)); tag = 'ol' if ordered else 'ul'
items = []
while i < len(lines) and re.match(r'^\s*([-*+]|\d+\.)\s+', lines[i]):
items.append(re.sub(r'^\s*([-*+]|\d+\.)\s+', '', lines[i])); i += 1
out.append(f'<{tag}>' + ''.join(f'<li>{inline(x)}</li>' for x in items) + f'</{tag}>')
continue
if L.strip() == '':
i += 1; continue
buf = []
while i < len(lines) and lines[i].strip() and not re.match(r'^(#{1,6}\s|\s*\||>|\s*([-*+]|\d+\.)\s)', lines[i]):
buf.append(lines[i].rstrip()); i += 1
if not buf:
buf.append(lines[i].rstrip()); i += 1
out.append('<p>' + inline(' '.join(buf)) + '</p>')
return '\n'.join(out)
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Play:wght@400;700&display=swap');
@page { size: A4; margin: 20mm 18mm 20mm 18mm; }
body { font-family: 'Play', 'Helvetica Neue', sans-serif; font-size: 10.5pt; line-height: 1.5; color: #000; }
h1 { font-size: 16pt; margin: 0 0 4pt; }
h2 { font-size: 12.5pt; margin: 16pt 0 5pt; border-bottom: .5pt solid #999; padding-bottom: 2pt; page-break-after: avoid; }
h3 { font-size: 11pt; margin: 12pt 0 4pt; page-break-after: avoid; }
p { margin: 0 0 6pt; text-align: justify; }
table { border-collapse: collapse; width: 100%; margin: 6pt 0 10pt; font-size: 9.5pt; page-break-inside: auto; }
th, td { border: .5pt solid #666; padding: 3pt 5pt; vertical-align: top; text-align: left; }
th { background: #eee; font-weight: bold; }
tr { page-break-inside: avoid; }
blockquote { margin: 6pt 0 8pt; padding: 5pt 9pt; border-left: 2pt solid #666; background: #f6f6f6; }
blockquote p { margin: 0 0 4pt; }
li { margin-bottom: 3pt; }
code { font-family: 'Play', monospace; font-size: 9.5pt; }
hr { border: 0; border-top: .5pt solid #bbb; margin: 10pt 0; }
a { color: #000; text-decoration: none; }
sup.ref { font-size: 7.5pt; line-height: 0; vertical-align: super; padding-left: .5pt; }
.notes { page-break-before: auto; margin-top: 16pt; }
.notes h2 { font-size: 11pt; }
.notes ol { list-style: none; padding-left: 0; font-size: 9pt; column-count: 2; column-gap: 14pt; }
.notes li { margin-bottom: 2.5pt; break-inside: avoid; }
.notes .n { display: inline-block; min-width: 13pt; font-weight: bold; }
.footer { margin-top: 14pt; padding-top: 5pt; border-top: .5pt solid #bbb; font-size: 8pt; color: #555; }
"""
def page(md, title, stamp, source=None):
SOURCE[0] = source
REFS.clear()
REF_INDEX.clear()
body = convert(md)
notes = ""
if REFS:
rows = "".join(
f'<li><span class="n">{i}</span> {html.escape(t)}</li>'
for i, t in enumerate(REFS, 1))
notes = f'<div class="notes"><h2>References</h2><ol>{rows}</ol></div>'
return (f"<!doctype html><meta charset='utf-8'><title>{html.escape(title)}</title>"
f"<style>{CSS}</style>{body}{notes}"
f"<div class='footer'>PT. Cyber Valley Estate · {html.escape(title)} · {stamp}</div>")
BROWSERS = [
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
]
def browser():
for b in BROWSERS:
if os.path.exists(b):
return b
sys.exit("no Chromium-based browser found — install Brave or Chrome")
def main():
ap = argparse.ArgumentParser(description="markdown pages to corporate PDF")
ap.add_argument("pages", nargs="+", help="markdown files")
ap.add_argument("-o", "--out", default="pdf", help="output directory")
ap.add_argument("--stamp", default=None, help="date printed in the footer")
a = ap.parse_args()
stamp = a.stamp or datetime.date.today().strftime("%d %B %Y")
out = os.path.expanduser(a.out)
os.makedirs(out, exist_ok=True)
tmp = tempfile.mkdtemp(prefix="md2pdf-")
exe = browser()
build_index(os.path.dirname(os.path.abspath(a.pages[0])) or ".")
build_index(".")
for src in a.pages:
name = os.path.basename(src)[:-3]
html_path = os.path.join(tmp, name + ".html")
with open(html_path, "w", encoding="utf-8") as f:
f.write(page(open(src, encoding="utf-8").read(), name, stamp, src))
pdf = os.path.join(out, name + ".pdf")
subprocess.run([exe, "--headless", "--disable-gpu", "--no-pdf-header-footer",
"--virtual-time-budget=9000", f"--print-to-pdf={pdf}",
pathlib.Path(html_path).absolute().as_uri()],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
size = os.path.getsize(pdf) // 1024 if os.path.exists(pdf) else 0
print(f"{name}.pdf {size} KB")
if __name__ == "__main__":
main()