1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:37:52 +03:00

TOC inside the docs

This commit is contained in:
Anton Afanasyeu
2026-06-03 06:23:08 +02:00
parent 4527e97dc0
commit bd339ee90c
24 changed files with 2768 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Build docs/20260602_REVERSE_SSH_proposals_summary.pdf from markdown source + heading TOC."""
from __future__ import annotations
from pathlib import Path
from md_to_pdf import build_pdf_from_markdown
ROOT = Path(__file__).resolve().parents[2]
SOURCE_MD = ROOT / "docs" / "20260602_REVERSE_SSH_proposals_summary.md"
OUT_PDF = ROOT / "docs" / "20260602_REVERSE_SSH_proposals_summary.pdf"
def build_pdf() -> None:
build_pdf_from_markdown(
SOURCE_MD,
OUT_PDF,
doc_subtitle="Generated from markdown — rev. 1 + rev. 2 + rev. 3 (2026-06-02)",
)
if __name__ == "__main__":
build_pdf()

View File

@@ -0,0 +1,48 @@
"""Shared markdown heading / slug helpers for docs PDF and TOC scripts."""
from __future__ import annotations
import re
TOC_START = "<!-- toc -->"
TOC_END = "<!-- /toc -->"
def github_slug(text: str) -> str:
"""GitHub/Cursor-compatible heading anchor."""
text = text.strip().lower()
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"[*_]+", "", text)
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def collect_headings(lines: list[str]) -> list[tuple[int, str, str]]:
"""Return (level, title, slug) for ## and ### headings."""
out: list[tuple[int, str, str]] = []
used: dict[str, int] = {}
for line in lines:
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if not m:
continue
level = len(m.group(1))
title = m.group(2).strip()
if title == "Table of contents":
continue
base = github_slug(title)
if not base:
continue
n = used.get(base, 0)
slug = base if n == 0 else f"{base}-{n}"
used[base] = n + 1
out.append((level, title, slug))
return out
def strip_md_link_text(title: str) -> str:
t = re.sub(r"\*\*([^*]+)\*\*", r"\1", title)
t = re.sub(r"`([^`]+)`", r"\1", t)
return t

View File

@@ -0,0 +1,318 @@
"""Render a markdown file to PDF with TOC from ## / ### headings (internal links + outline)."""
from __future__ import annotations
import re
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
Flowable,
PageBreak,
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from md_common import TOC_END, TOC_START, collect_headings, github_slug, strip_md_link_text
def _xml(text: str) -> str:
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _inline_md(text: str) -> str:
text = _xml(text)
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
text = re.sub(r"`([^`]+)`", r'<font face="Courier" size="7">\1</font>', text)
text = re.sub(
r"\[([^\]]+)\]\(#([^)]+)\)",
r'<a href="#\2" color="#1565C0">\1</a>',
text,
)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
return text
class AnchorParagraph(Paragraph):
"""Paragraph with PDF bookmark anchor."""
def __init__(self, text, style, *, anchor: str, outline_level: int):
super().__init__(f'<a name="{anchor}"/>{text}', style)
self._anchor = anchor
self._outline_level = outline_level
class OutlineDocTemplate(SimpleDocTemplate):
def afterFlowable(self, flowable: Flowable) -> None:
if isinstance(flowable, AnchorParagraph):
self.canv.bookmarkPage(flowable._anchor)
self.canv.addOutlineEntry(
flowable.getPlainText(),
flowable._anchor,
level=max(0, flowable._outline_level - 2),
)
def _cell(text: str, style: ParagraphStyle) -> Paragraph:
return Paragraph(_inline_md(text), style)
def _parse_table_row(line: str) -> list[str]:
line = line.strip()
if line.startswith("|"):
line = line[1:]
if line.endswith("|"):
line = line[:-1]
return [c.strip() for c in line.split("|")]
def _is_table_sep(line: str) -> bool:
return bool(re.match(r"^\|?[\s\-:|]+\|?$", line.strip()))
def build_slug_by_line(lines: list[str]) -> dict[int, str]:
"""Map source line index → anchor slug (same rules as collect_headings)."""
out: dict[int, str] = {}
used: dict[str, int] = {}
for i, line in enumerate(lines):
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if not m:
continue
title = m.group(2).strip()
if title == "Table of contents":
continue
base = github_slug(title)
if not base:
continue
n = used.get(base, 0)
slug = base if n == 0 else f"{base}-{n}"
used[base] = n + 1
out[i] = slug
return out
def build_pdf_from_markdown(
source_md: Path,
out_pdf: Path,
*,
doc_title: str | None = None,
doc_subtitle: str = "",
) -> None:
lines = source_md.read_text(encoding="utf-8").splitlines()
headings = collect_headings(lines)
slug_by_line = build_slug_by_line(lines)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"DocTitle",
parent=styles["Title"],
fontSize=15,
spaceAfter=5,
textColor=colors.HexColor("#1565C0"),
)
toc_title = ParagraphStyle(
"TocTitle",
parent=styles["Heading1"],
fontSize=12,
spaceBefore=4,
spaceAfter=6,
textColor=colors.HexColor("#1565C0"),
)
toc_item = ParagraphStyle(
"TocItem",
parent=styles["Normal"],
fontSize=8,
leading=11,
leftIndent=0,
)
toc_sub = ParagraphStyle(
"TocSub",
parent=toc_item,
leftIndent=14,
fontSize=7.5,
leading=10,
)
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=11, spaceBefore=10, spaceAfter=4)
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=9.5, spaceBefore=6, spaceAfter=3)
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=8, leading=10.5, alignment=TA_LEFT)
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=12, bulletIndent=4, fontSize=8, leading=10.5)
small = ParagraphStyle("Small", parent=body, fontSize=7, textColor=colors.grey)
code_style = ParagraphStyle("Code", parent=body, fontName="Courier", fontSize=6.5, leading=8)
tbl_head = ParagraphStyle("TblHead", parent=body, fontName="Helvetica-Bold", fontSize=7, leading=9)
tbl_body = ParagraphStyle("TblBody", parent=body, fontSize=7, leading=9)
story: list = []
# Title from first # line if not overridden
if doc_title is None:
for line in lines:
if line.startswith("# "):
doc_title = line[2:].strip()
break
doc_title = doc_title or source_md.stem
story.append(Paragraph(_inline_md(doc_title), title_style))
if doc_subtitle:
story.append(Paragraph(_inline_md(doc_subtitle), small))
story.append(Spacer(1, 0.15 * cm))
# Table of contents (clickable internal links)
story.append(Paragraph("Table of contents", toc_title))
for level, title, slug in headings:
text = strip_md_link_text(title)
link = f'<a href="#{slug}" color="#1565C0">{_xml(text)}</a>'
style = toc_sub if level == 3 else toc_item
story.append(Paragraph(link, style))
story.append(Spacer(1, 0.2 * cm))
story.append(
Paragraph(
f"<i>Source: {source_md.name} — section links work in PDF viewers that support internal anchors.</i>",
small,
)
)
story.append(PageBreak())
# Body — skip title, TOC block, index link
skip_toc = False
in_code = False
code_lines: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
if line.strip() == TOC_START:
skip_toc = True
i += 1
continue
if line.strip() == TOC_END:
skip_toc = False
i += 1
continue
if skip_toc:
i += 1
continue
if line.startswith("# ") and not line.startswith("## "):
i += 1
continue
if line.strip() == "## Table of contents":
i += 1
continue
if line.strip().startswith("**Documentation index:**"):
i += 1
continue
if line.strip().startswith("```"):
if in_code:
story.append(Preformatted("\n".join(code_lines), code_style))
story.append(Spacer(1, 0.08 * cm))
code_lines = []
in_code = False
else:
in_code = True
i += 1
continue
if in_code:
code_lines.append(line)
i += 1
continue
if line.startswith("### "):
title = line[4:].strip()
slug = slug_by_line.get(i, github_slug(title))
story.append(
AnchorParagraph(_inline_md(title), h2, anchor=slug, outline_level=3)
)
i += 1
continue
if line.startswith("## "):
title = line[3:].strip()
slug = slug_by_line.get(i, github_slug(title))
story.append(
AnchorParagraph(_inline_md(title), h1, anchor=slug, outline_level=2)
)
i += 1
continue
if line.strip() == "---":
story.append(Spacer(1, 0.1 * cm))
i += 1
continue
if line.strip().startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]):
headers = _parse_table_row(line)
i += 2
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
rows.append(_parse_table_row(lines[i]))
i += 1
ncol = len(headers)
if ncol:
width = (16.2 * cm) / ncol
data = [[_cell(h, tbl_head) for h in headers]]
for row in rows:
padded = row + [""] * (ncol - len(row))
data.append([_cell(c, tbl_body) for c in padded[:ncol]])
t = Table(data, colWidths=[width] * ncol)
t.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 7),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
]
)
)
story.append(t)
story.append(Spacer(1, 0.08 * cm))
continue
if line.strip().startswith("- "):
story.append(Paragraph(f"{_inline_md(line.strip()[2:])}", bullet))
i += 1
continue
if line.strip().startswith("_") and line.strip().endswith("_") and len(line.strip()) > 2:
story.append(Paragraph(f"<i>{_inline_md(line.strip()[1:-1])}</i>", small))
i += 1
continue
if not line.strip():
i += 1
continue
story.append(Paragraph(_inline_md(line.strip()), body))
i += 1
doc = OutlineDocTemplate(
str(out_pdf),
pagesize=A4,
leftMargin=1.4 * cm,
rightMargin=1.4 * cm,
topMargin=1.3 * cm,
bottomMargin=1.3 * cm,
title=doc_title,
author="Android Cast project",
)
doc.build(story)
print(f"Wrote {out_pdf} ({len(headings)} TOC entries from markdown headings)")