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:
24
docs/_pdf_build/build_reverse_ssh_proposals_pdf.py
Normal file
24
docs/_pdf_build/build_reverse_ssh_proposals_pdf.py
Normal 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()
|
||||
48
docs/_pdf_build/md_common.py
Normal file
48
docs/_pdf_build/md_common.py
Normal 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
|
||||
318
docs/_pdf_build/md_to_pdf.py
Normal file
318
docs/_pdf_build/md_to_pdf.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
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)")
|
||||
Reference in New Issue
Block a user