initial
408
_pdf_build/20260612_MEDIASERVICE_AI_requirements.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# Media service (SFU / MCU) — AI deployment prerequisites
|
||||
|
||||
|
||||
<!-- doc-meta:start -->
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Author | Anton Afanasyeu |
|
||||
| Revision | R1 |
|
||||
| Creation date | 2026-06-12 |
|
||||
| Last modification date | 2026-06-12 |
|
||||
| Co-authored | |
|
||||
| Severity | medium |
|
||||
<!-- doc-meta:end -->
|
||||
|
||||
**Document type:** Requirements / handoff (pre-SPEC)
|
||||
**Date:** 2026-06-12
|
||||
**Audience:** PO / sysop → AI agent for full deploy + tune on fresh Alpine HVM(s)
|
||||
**Related:** [docs/specs/20100612_1_scaling.md](../docs/specs/20100612_1_scaling.md), [docs/DRs/20100612_1_scaling.md](../docs/DRs/20100612_1_scaling.md), [examples/sfu/README.md](../examples/sfu/README.md), [docs/20260608_BE_SERVICES_and_infra.md](../20260608_BE_SERVICES_and_infra.md) §SFU
|
||||
**PDF:** `tmp/20260612-MEDIASERVICE-AI-requirements.pdf` — regenerate with `.venv-pdf/bin/python docs/_pdf_build/md_to_pdf.py` on the copy under `tmp/`
|
||||
**Code names (product):** **SPIDERWEB** = SFU-alike, **SPIDER** = MCU-alike ([draft glossary](../docs/drafts/20100611_1_net_topology_glossary.txt))
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
\newpage
|
||||
## Table of contents
|
||||
|
||||
<!-- toc -->
|
||||
- [1. Executive answer — HVM vs Docker vs monolith BE](#1-executive-answer-hvm-vs-docker-vs-monolith-be)
|
||||
- [2. What you give the AI on day zero](#2-what-you-give-the-ai-on-day-zero)
|
||||
- [3. Recommended instance layout (up to 3 HVMs)](#3-recommended-instance-layout-up-to-3-hvms)
|
||||
- [4. Network, DNS, and firewall prerequisites](#4-network-dns-and-firewall-prerequisites)
|
||||
- [5. Integration with existing BE (isolation model)](#5-integration-with-existing-be-isolation-model)
|
||||
- [6. SFU vs MCU stack choice](#6-sfu-vs-mcu-stack-choice)
|
||||
- [7. Packages and software (installed later by AI)](#7-packages-and-software-installed-later-by-ai)
|
||||
- [8. Sizing and bandwidth (200/200 Mbit context)](#8-sizing-and-bandwidth-200200-mbit-context)
|
||||
- [9. AI workflow after prerequisites are met](#9-ai-workflow-after-prerequisites-are-met)
|
||||
- [10. Out of scope for first media PoP](#10-out-of-scope-for-first-media-pop)
|
||||
- [11. PO checklist (copy before handoff)](#11-po-checklist-copy-before-handoff)
|
||||
- [12. Changelog](#12-changelog)
|
||||
<!-- /toc -->
|
||||
\newpage
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive answer — HVM vs Docker vs monolith BE
|
||||
|
||||
### Recommendation (DR-aligned)
|
||||
|
||||
| Approach | Verdict | Why |
|
||||
|----------|---------|-----|
|
||||
| **SFU/MCU on current androidcast BE monolith** | **No** | PHP/MariaDB/RSSH/Gitea share CPU/RAM/NIC; `pm.max_children=5` today; blast radius; [INFRA.md](../docs/INFRA.md) keeps `:8089` as **other** Janus stack |
|
||||
| **Fresh dedicated Alpine HVM(s)** | **Yes (primary)** | Isolated **media plane**; snapshots; UDP/WebRTC without touching hub/crashes |
|
||||
| **Docker on dedicated HVM** | **Yes (preferred delivery)** | Reproducible Janus/mediasoup/coturn/ffmpeg; easy rollback; same host still “empty” at handoff |
|
||||
| **Docker on existing BE** | **No** | Does not isolate bandwidth or failure domain |
|
||||
|
||||
**Practical model:** PO provides **1–3 empty Alpine HVMs** with SSH + DNS. AI installs **Docker (+ compose)** on them and runs **SPIDERWEB (SFU)** or **SPIDER (MCU)** stacks in containers. **Control plane** (signaling auth, room lifecycle, RBAC) talks to existing crashes BE via **HTTPS/OpenAPI-style APIs** — no co-location of MariaDB or PHP-FPM with media.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Today["Today — monolith BE (unchanged)"]
|
||||
FE["Gentoo FE apps.f0xx.org"]
|
||||
BE["Alpine BE :80<br/>PHP MariaDB Gitea RSSH"]
|
||||
end
|
||||
|
||||
subgraph New["New — media PoP (1–3 HVMs)"]
|
||||
MED["media-1 Alpine HVM"]
|
||||
DOCK["Docker: SFU + TURN + signaling sidecar"]
|
||||
end
|
||||
|
||||
MOB["Android app"] --> FE
|
||||
FE --> BE
|
||||
MOB -.->|"WebRTC UDP/TCP<br/>post-alpha"| MED
|
||||
DOCK --> MED
|
||||
BE -.->|"REST: token room RBAC<br/>OpenAPI"| DOCK
|
||||
|
||||
class New fill:#e8f5e9,stroke:#2e7d32
|
||||
```
|
||||
|
||||
**Not too heavy:** one HVM + Docker Compose is enough for **lab/stress**; three HVMs match **prod-shaped** split (SFU / TURN / transcode). Start with **1**; split when metrics or PO asks.
|
||||
|
||||
---
|
||||
|
||||
## 2. What you give the AI on day zero
|
||||
|
||||
Minimum handoff — **no media packages required yet** on the empty machine.
|
||||
|
||||
### 2.1 Access
|
||||
|
||||
| Item | Required | Example |
|
||||
|------|----------|---------|
|
||||
| **SSH target** | Yes | `ssh media@media-lab.example.org` or intra `10.7.x.x` |
|
||||
| **Sudo** | Yes | passwordless `sudo` for `apk`, `rc-service`, docker |
|
||||
| **SSH key** | Yes | laptop → HVM; optional: HVM → git read for deploy scripts |
|
||||
| **Jump host** | If no public SSH | `ProxyJump` via `f0xx-monstro` / `alpine-be` (document in `~/.ssh/config`) |
|
||||
|
||||
### 2.2 Machine identity
|
||||
|
||||
| Item | Required | Notes |
|
||||
|------|----------|-------|
|
||||
| **Hostname** | Yes | e.g. `media-1.intra.raptor.org` |
|
||||
| **OS** | Yes | Alpine 3.x x86_64 (match prod BE) |
|
||||
| **vCPU / RAM** | Yes | See [§8](#8-sizing-and-bandwidth-200200-mbit-context) |
|
||||
| **Root disk** | ≥ 40 GiB SSD | containers + logs |
|
||||
| **Intra IP** | Yes | `10.7.0.0/8` address stable across reboots |
|
||||
| **Public reach** | For external test | DNS name OR FE proxy only (see [§4](#4-network-dns-and-firewall-prerequisites)) |
|
||||
|
||||
### 2.3 PO-owned (snapshots, DNS, firewall)
|
||||
|
||||
| Item | Owner | AI needs |
|
||||
|------|-------|----------|
|
||||
| **VM snapshots** | PO | “Snapshot before AI session” + rollback name |
|
||||
| **External DNS** | PO | A/AAAA or CNAME for WebRTC/signaling test |
|
||||
| **Firewall / DNAT** | PO | UDP/TCP port list from [§4](#4-network-dns-and-firewall-prerequisites) opened on router/FE |
|
||||
| **TLS certs** | PO or AI via DNS-01 | Let’s Encrypt on FE or on media host |
|
||||
|
||||
### 2.4 Integration secrets (existing BE — read-only API)
|
||||
|
||||
| Item | Required | Purpose |
|
||||
|------|----------|---------|
|
||||
| **Crashes base URL** | Yes | `https://apps.f0xx.org/app/androidcast_project/crashes` |
|
||||
| **Service account or API token** | Yes | Mint rooms / validate session (new `sfu` API or extend OpenAPI) |
|
||||
| **RBAC model** | Reference | Same pattern as remote access / short links |
|
||||
| **Gitea / deploy** | Optional | Pull `android_cast` branch `feature/sfu-relay` or deploy tarball |
|
||||
|
||||
AI does **not** need MariaDB root on monolith BE if all integration is **HTTP APIs**.
|
||||
|
||||
### 2.5 Decisions PO should state (or defaults)
|
||||
|
||||
| Decision | Default if silent |
|
||||
|----------|-------------------|
|
||||
| **Topology code name** | **SPIDERWEB** (SFU) first; MCU later |
|
||||
| **Engine** | **mediasoup** (greenfield) — Janus on `:8089` is **other** stack, avoid unless PO picks Janus on **new** host |
|
||||
| **Instance count** | **1** for lab; **3** for prod-shaped |
|
||||
| **Public path** | Subdomain `media.apps.f0xx.org` → FE → media HVM (keeps TLS at FE) |
|
||||
| **TURN** | **coturn** on same HVM (lab) or instance 2 (prod) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended instance layout (up to 3 HVMs)
|
||||
|
||||
### 3.1 Lab — single HVM (minimum handoff)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph HVM1["media-1 (single Alpine HVM)"]
|
||||
NG["nginx :443 reverse proxy"]
|
||||
SIG["signaling sidecar<br/>Node or Go"]
|
||||
SFU["mediasoup / Janus SFU"]
|
||||
TURN["coturn STUN/TURN"]
|
||||
FF["ffmpeg worker optional"]
|
||||
end
|
||||
|
||||
Client["App / browser"] --> NG
|
||||
NG --> SIG
|
||||
Client --> TURN
|
||||
Client --> SFU
|
||||
SIG --> SFU
|
||||
BE["Existing BE PHP"] -.->|HTTPS| SIG
|
||||
```
|
||||
|
||||
All services in **one docker-compose** stack; fine for stress and first WAN cast tests.
|
||||
|
||||
### 3.2 Prod-shaped — three HVMs
|
||||
|
||||
| VM | Role | Containers / daemons |
|
||||
|----|------|----------------------|
|
||||
| **media-1** | **SPIDERWEB SFU** | mediasoup (or Janus), nginx edge, signaling sidecar |
|
||||
| **media-2** | **TURN + signaling** | coturn, optional dedicated signaling if scaled |
|
||||
| **media-3** | **Transcode / MCU path** | ffmpeg workers, optional MCU (SPIDER) if not SFU-only |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
FE["FE TLS"] --> M1["media-1 SFU"]
|
||||
FE --> M2["media-2 TURN"]
|
||||
APP["Android"] -->|UDP WebRTC| M1
|
||||
APP -->|UDP TURN| M2
|
||||
BE["Monolith BE APIs"] -->|REST| M1
|
||||
M1 --> M3["media-3 ffmpeg optional"]
|
||||
```
|
||||
|
||||
PO can hand **one** machine first; AI documents split boundaries so instances 2–3 are **additive**, not rewrite.
|
||||
|
||||
---
|
||||
|
||||
## 4. Network, DNS, and firewall prerequisites
|
||||
|
||||
### 4.1 Traffic paths
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Internet["Internet clients"]
|
||||
Router["Router 200/200 Mbit"]
|
||||
FE["Gentoo FE"]
|
||||
M["Media HVM(s)"]
|
||||
BE["Androidcast BE :80"]
|
||||
|
||||
Internet --> Router --> FE
|
||||
FE -->|"HTTPS signaling<br/>wss / REST"| M
|
||||
FE --> BE
|
||||
Internet -->|"UDP WebRTC<br/>after DNAT or 1:1"| M
|
||||
```
|
||||
|
||||
**Do not** attach media to androidcast **`:8089`** vhost ([INFRA.md](../docs/INFRA.md) §2).
|
||||
|
||||
### 4.2 Ports to plan (PO opens on FE/router → media HVM)
|
||||
|
||||
| Port / proto | Service | Notes |
|
||||
|--------------|---------|-------|
|
||||
| **443/tcp** | HTTPS / WSS signaling | Via FE reverse proxy recommended |
|
||||
| **3478/udp,tcp** | STUN/TURN (coturn) | Required for many NATs |
|
||||
| **49152–65535/udp** | WebRTC media (ICE) | Range size depends on engine; document in SFU SPEC |
|
||||
| **8080/tcp** | SFU HTTP API (internal) | **Not** public; FE or intra only |
|
||||
| **22/tcp** | SSH | Intra or VPN only in prod |
|
||||
|
||||
Exact ICE port range: set in mediasoup/Janus config during deploy; PO must allow **UDP range** on path to media HVM.
|
||||
|
||||
### 4.3 DNS names (examples)
|
||||
|
||||
| Name | Points to | Use |
|
||||
|------|-----------|-----|
|
||||
| `media.apps.f0xx.org` | FE → proxy → media-1 | Public signaling + health |
|
||||
| `turn.apps.f0xx.org` | FE or media-2 | TURN URI in app |
|
||||
| `media-lab.intra.raptor.org` | 10.7.x.x | AI + sysop intra testing |
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration with existing BE (isolation model)
|
||||
|
||||
Media HVM **must not** share MariaDB or PHP-FPM with crashes console. Integration is **API-only**:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Android app
|
||||
participant FE as FE nginx
|
||||
participant BE as Crashes BE PHP
|
||||
participant SIG as Media signaling
|
||||
participant SFU as SFU engine
|
||||
|
||||
App->>FE: HTTPS session / cast token
|
||||
FE->>BE: existing auth APIs
|
||||
App->>SIG: WSS create room (bearer)
|
||||
SIG->>BE: POST validate token / RBAC
|
||||
BE-->>SIG: room allowed + ttl
|
||||
SIG->>SFU: create room
|
||||
App->>SFU: WebRTC publish/subscribe UDP
|
||||
```
|
||||
|
||||
| Integration | Method | Existing artifact |
|
||||
|-------------|--------|-------------------|
|
||||
| Health gate | GET | `api/sfu_health.php` (stub today) |
|
||||
| Feature flag | config | `config.php` → `sfu.enabled`, URLs |
|
||||
| Room mint / revoke | **New REST** | OpenAPI under crashes or `media-api` submodule |
|
||||
| Auth | Bearer + session RBAC | Same as remote access / short links pattern |
|
||||
| Metrics | POST ingest | Optional → `graph_upload.php` or new series |
|
||||
| No direct DB | — | Media DB (if any) is **local** to HVM (Redis/SQLite) |
|
||||
|
||||
**OpenAPI:** extend [examples/crash_reporter/backend](../examples/crash_reporter/backend) or new `backend/media-signaling` repo later — AI needs **written** API contract before prod wire-up.
|
||||
|
||||
---
|
||||
|
||||
## 6. SFU vs MCU stack choice
|
||||
|
||||
| | **SPIDERWEB (SFU)** | **SPIDER (MCU)** |
|
||||
|---|---------------------|------------------|
|
||||
| **Use** | Multiparty cast; subscribers receive per-stream | Mix down; single output; higher CPU per room |
|
||||
| **Engine candidates** | **mediasoup** (preferred greenfield), Janus VideoRoom | Janus MCU plugin, ffmpeg bridge, custom |
|
||||
| **CPU** | Moderate per stream; scales with subscribers | High per room (encode) |
|
||||
| **Latency** | Lower | Higher |
|
||||
| **First milestone** | **Yes** | Phase 2 / media-3 |
|
||||
|
||||
**Janus on existing `:8089`:** treat as **foreign** — do not piggyback androidcast nginx. New Janus instance on **media HVM** only if PO rejects mediasoup.
|
||||
|
||||
**Docker vs bare metal on HVM:** use **Docker** for SFU/TURN/signaling; host only needs Alpine + docker + nginx + firewall tools.
|
||||
|
||||
---
|
||||
|
||||
## 7. Packages and software (installed later by AI)
|
||||
|
||||
PO delivers **almost empty** Alpine; AI installs when handoff complete.
|
||||
|
||||
### 7.1 Host (Alpine packages)
|
||||
|
||||
```text
|
||||
# Baseline (all media HVMs)
|
||||
openssh sudo bash curl wget git ca-certificates
|
||||
nginx openssl iptables ip6tables
|
||||
docker docker-cli-compose
|
||||
# Optional host tools
|
||||
tcpdump bind-tools jq python3
|
||||
|
||||
# If bare-metal fallback (no Docker) — not recommended first
|
||||
# janus-gateway mediasoup — prefer official containers
|
||||
```
|
||||
|
||||
### 7.2 Container images (typical compose)
|
||||
|
||||
| Service | Image / build | Role |
|
||||
|---------|---------------|------|
|
||||
| **mediasoup** or **janus** | Official or pinned Dockerfile in repo | SFU |
|
||||
| **coturn** | `coturn/coturn` | STUN/TURN |
|
||||
| **signaling** | Custom Node/Go from repo | Rooms, BE API client |
|
||||
| **nginx** | `nginx:alpine` or host nginx | TLS termination at edge or intra |
|
||||
| **ffmpeg** | `jrottenberg/ffmpeg` or alpine | Transcode sidecar (media-3) |
|
||||
| **redis** | `redis:alpine` | Room state optional |
|
||||
|
||||
### 7.3 Repo deliverables (AI produces after deploy)
|
||||
|
||||
| Artifact | Location (proposed) |
|
||||
|----------|---------------------|
|
||||
| `docker-compose.yml` | `examples/sfu/deploy/` or `backend/media-pop/` |
|
||||
| nginx FE fragment | `examples/sfu/deploy/nginx.fe-media.snippet` |
|
||||
| BE config keys | `config.example.php` → `sfu.*` (already stubbed) |
|
||||
| Smoke script | `examples/sfu/scripts/smoke_sfu.sh` |
|
||||
| OpenAPI | `examples/sfu/openapi.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Sizing and bandwidth (200/200 Mbit context)
|
||||
|
||||
WAN **200/200 Mbit** sits behind FE; media UDP may be **DNAT** to media HVM.
|
||||
|
||||
| Profile | vCPU | RAM | Notes |
|
||||
|---------|------|-----|-------|
|
||||
| **Lab / stress (1 HVM)** | **4–8** | **8–16 GiB** | SFU + TURN + signaling; ~10–30 parallel WebRTC streams |
|
||||
| **Prod SFU node** | **8+** | **16 GiB** | Dedicated NIC budget; see [SPEC §12.3](../docs/specs/20100612_1_scaling.md#123-network-bandwidth-and-egress) |
|
||||
| **TURN only** | **2** | **4 GiB** | Mostly relay bandwidth |
|
||||
| **ffmpeg node** | **8+** | **16–32 GiB** | CPU-bound transcodes |
|
||||
|
||||
**Bandwidth rule of thumb (SFU):**
|
||||
|
||||
| Streams | Approx WAN need |
|
||||
|---------|-----------------|
|
||||
| 10 × 1 Mbps video | **~10–15 Mbps** + overhead |
|
||||
| 50 × 1 Mbps | **~50–80 Mbps** — approaches 200 Mbit shared with rest of estate |
|
||||
| 100+ viewers | **CDN/SFU PoP or second site** — not single 200 Mbit |
|
||||
|
||||
Stress test: measure on **intra** first, then **public DNS** through FE.
|
||||
|
||||
---
|
||||
|
||||
## 9. AI workflow after prerequisites are met
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["PO: empty HVM + SSH + DNS + firewall list"] --> B["AI: verify ssh sudo intra ping"]
|
||||
B --> C["AI: apk + docker + compose"]
|
||||
C --> D["AI: deploy SFU stack lab compose"]
|
||||
D --> E["AI: nginx FE fragment + BE sfu.enabled URLs"]
|
||||
E --> F["Smoke: sfu_health + WSS + ICE"]
|
||||
F --> G{"PO: load test OK?"}
|
||||
G -->|yes| H["Document + optional 2nd/3rd HVM split"]
|
||||
G -->|no| I["Tune UDP range CPU docker limits"]
|
||||
I --> F
|
||||
```
|
||||
|
||||
| Step | AI action | PO action |
|
||||
|------|-----------|-----------|
|
||||
| 0 | — | Snapshot empty VM |
|
||||
| 1 | Probe CPU/RAM/NIC, `apk update` | Confirm DNS/firewall |
|
||||
| 2 | Install docker, compose, pin versions | — |
|
||||
| 3 | Bring up mediasoup + coturn + signaling | — |
|
||||
| 4 | FE nginx `location /sfu/` or subdomain proxy | Reload FE nginx |
|
||||
| 5 | Enable `sfu.enabled` + URLs on monolith BE **only** | Sync config.php |
|
||||
| 6 | Run smoke + optional load/WebRTC client | Review metrics |
|
||||
| 7 | Snapshot “golden” media PoP | — |
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope for first media PoP
|
||||
|
||||
- Replacing LAN UDP cast (alpha path unchanged)
|
||||
- Moving crashes/tickets/Gitea to media HVM
|
||||
- Full GEO policy ([SPEC §14](../docs/specs/20100612_1_scaling.md#14-global-availability-and-geo-policy)) — later with F3/F10
|
||||
- Production payments / recording storage
|
||||
- Merging with Janus stack on BE `:8089`
|
||||
|
||||
---
|
||||
|
||||
## 11. PO checklist (copy before handoff)
|
||||
|
||||
```text
|
||||
[ ] Alpine 3.x HVM provisioned (1–3); specs noted
|
||||
[ ] SSH + sudo for AI; key installed
|
||||
[ ] Intra IP 10.7.x.x stable
|
||||
[ ] External DNS name for test (or FE-only proxy agreed)
|
||||
[ ] Firewall: UDP 3478 + WebRTC range → media HVM
|
||||
[ ] Snapshot taken (empty baseline)
|
||||
[ ] Decision: SPIDERWEB (SFU) first — mediasoup default
|
||||
[ ] Crashes BE base URL + API token for room validation
|
||||
[ ] Confirm: do NOT use androidcast :8089 / monolith BE for SFU
|
||||
[ ] Optional: 2nd/3rd HVM reserved for TURN + ffmpeg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-06-12 | Initial prerequisites — HVM vs Docker, 3-instance layout, BE API isolation, PO checklist |
|
||||
|
||||
---
|
||||
|
||||
**Documentation index:** [docs/README.md](../docs/README.md)
|
||||
67
_pdf_build/build_all_docs_pdf.py
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build **/*.pdf for every **/*.md (recursive; skips _pdf_build/, drafts/)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_BUILD_DIR = Path(__file__).resolve().parent
|
||||
if (_BUILD_DIR.parent / "docs").is_dir():
|
||||
ROOT = _BUILD_DIR.parent
|
||||
DOCS = ROOT / "docs"
|
||||
else:
|
||||
ROOT = _BUILD_DIR.parent
|
||||
DOCS = ROOT
|
||||
|
||||
sys.path.insert(0, str(_BUILD_DIR))
|
||||
from md_to_pdf import build_pdf_from_markdown # noqa: E402
|
||||
|
||||
SUBTITLE_BY_REL = {
|
||||
"specs/20100611_3_url_shortener.md": "URL shortener service — SPEC (post-alpha, s.f0xx.org)",
|
||||
"DRs/20100611_3_url_shortener.md": "URL shortener service — design review",
|
||||
"specs/20100612_1_scaling.md": "Platform scaling — SPEC (clusters, bandwidth, GEO, cloud path)",
|
||||
"DRs/20100612_1_scaling.md": "Platform scaling — DR (architecture, bandwidth, GEO policy, task graph)",
|
||||
"DRs/20260618_repos_reorganizing.md": "Repository & microservice split — frozen DR R1.1",
|
||||
"specs/20260618_repos_reorganizing.md": "Repository & microservice split — SPEC R1 (normative)",
|
||||
"20260620_postgres_timescale_db_platform.md": "PostgreSQL, TimescaleDB, platform DB adapters — advisory",
|
||||
}
|
||||
|
||||
|
||||
def iter_markdown_files() -> list[Path]:
|
||||
out: list[Path] = []
|
||||
for md in sorted(DOCS.rglob("*.md")):
|
||||
if "_pdf_build" in md.parts or "drafts" in md.parts:
|
||||
continue
|
||||
out.append(md)
|
||||
return out
|
||||
|
||||
|
||||
def rel_label(md: Path) -> str:
|
||||
try:
|
||||
return str(md.relative_to(DOCS))
|
||||
except ValueError:
|
||||
return md.name
|
||||
|
||||
|
||||
def main() -> int:
|
||||
md_files = iter_markdown_files()
|
||||
if not md_files:
|
||||
print("No markdown files found", file=sys.stderr)
|
||||
return 1
|
||||
ok = 0
|
||||
for md in md_files:
|
||||
out = md.with_suffix(".pdf")
|
||||
rel = rel_label(md)
|
||||
subtitle = SUBTITLE_BY_REL.get(rel, f"From {rel}")
|
||||
try:
|
||||
build_pdf_from_markdown(md, out, doc_subtitle=subtitle)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print(f"FAIL {rel}: {e}", file=sys.stderr)
|
||||
print(f"Built {ok}/{len(md_files)} PDFs under {DOCS}/")
|
||||
return 0 if ok == len(md_files) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
384
_pdf_build/build_egress_review_pdf.py
Normal file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build 20260521_next_gen_steps_egress_review.pdf from summary text and diagram PNGs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
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 (
|
||||
Image,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
from page_footer import NumberedCanvas
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BUILD = Path(__file__).resolve().parent
|
||||
OUT_PDF = ROOT / "20260521_next_gen_steps_egress_review.pdf"
|
||||
|
||||
MM = [
|
||||
("diagram_current_arch.mmd", "diagram_current_arch.png", "Current sender path (1 encode, N unicast sends)"),
|
||||
("diagram_multicast_proposal.mmd", "diagram_multicast_proposal.png", "Proposed multicast setup (handshake then one media flow)"),
|
||||
]
|
||||
|
||||
|
||||
def render_mermaid() -> None:
|
||||
for mmd, png, _ in MM:
|
||||
src = BUILD / mmd
|
||||
dst = BUILD / png
|
||||
subprocess.run(
|
||||
[
|
||||
"npx",
|
||||
"--yes",
|
||||
"@mermaid-js/mermaid-cli@11.4.0",
|
||||
"-i",
|
||||
str(src),
|
||||
"-o",
|
||||
str(dst),
|
||||
"-b",
|
||||
"white",
|
||||
"-w",
|
||||
"1400",
|
||||
"-H",
|
||||
"900",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
|
||||
def p(text: str, style) -> Paragraph:
|
||||
return Paragraph(text.replace("\n", "<br/>"), style)
|
||||
|
||||
|
||||
def build_pdf() -> None:
|
||||
styles = getSampleStyleSheet()
|
||||
title = ParagraphStyle(
|
||||
"DocTitle",
|
||||
parent=styles["Title"],
|
||||
fontSize=18,
|
||||
spaceAfter=12,
|
||||
textColor=colors.HexColor("#1565C0"),
|
||||
)
|
||||
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=14, spaceAfter=8)
|
||||
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=12, spaceBefore=10, spaceAfter=6)
|
||||
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
|
||||
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
|
||||
|
||||
story = []
|
||||
story.append(p("Android Cast — Next-gen egress review", title))
|
||||
story.append(
|
||||
p(
|
||||
"Summary of sender 1:N behavior, bandwidth model, limitations, and feasibility "
|
||||
"of single-stream multicast plus multi-tier (full / 720p / 480p) renditions. "
|
||||
"Generated 2026-05-21.",
|
||||
small,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# --- Current state ---
|
||||
story.append(p("1. Current sender behavior", h1))
|
||||
story.append(
|
||||
p(
|
||||
"<b>How many receivers?</b> Up to 5 when MULTI_RECEIVER_ENABLED "
|
||||
"(CastConfig.MAX_RECEIVERS). Sender opens one CastSession per selected device; "
|
||||
"failed handshakes are skipped if at least one peer connects.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>CPU / GPU / memory:</b> Capture and encode run once per cast. "
|
||||
"CastFanoutPump duplicates each encoded frame to every session. "
|
||||
"Network send and per-peer UDP/FEC state scale with N; encode cost does not.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Bandwidth:</b> Per-receiver unicast duplicate of the same bitstream — not IP multicast. "
|
||||
"Rough LAN egress ≈ (video + ~96 kbps audio) × N, plus per-peer protocol overhead.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
img1 = BUILD / MM[0][1]
|
||||
if img1.exists():
|
||||
story.append(p(MM[0][2], h2))
|
||||
iw = 16 * cm
|
||||
story.append(Image(str(img1), width=iw, height=iw * 0.45))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# Table current limits
|
||||
story.append(p("1.1 Current limitations (sender-focused)", h2))
|
||||
limits = [
|
||||
["Area", "Limitation"],
|
||||
["Primary peer", "First connected peer sets negotiated MIME, adaptive tuning, and network feedback."],
|
||||
["Codec", "Single encoder; all peers must tolerate the same bitstream."],
|
||||
["Send path", "One CastFanoutPump thread; serial fan-out to N sessions."],
|
||||
["Wi‑Fi", "Uplink often saturates before CPU on 3–5 receivers."],
|
||||
["Topology", "Sender dials out; no shared LAN broadcast stream today."],
|
||||
]
|
||||
t = Table(limits, colWidths=[3.2 * cm, 13.3 * cm])
|
||||
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), 9),
|
||||
("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), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t)
|
||||
story.append(PageBreak())
|
||||
|
||||
# --- Proposal 1 ---
|
||||
story.append(p("2. Proposal: single outgoing stream (multicast)", h1))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Goal:</b> One encoded stream on the air; only handshake-authorized receivers consume it; "
|
||||
"save ~(N−1) × bitrate vs today’s unicast fan-out.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Feasible</b> with UDP IP multicast + unicast handshake (group address, port, sessionId, PIN). "
|
||||
"Handshake-only targeting without multicast does <b>not</b> reduce bandwidth. "
|
||||
"TCP/QUIC cannot deliver true 1× Wi‑Fi airtime.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
img2 = BUILD / MM[1][1]
|
||||
if img2.exists():
|
||||
story.append(p(MM[1][2], h2))
|
||||
iw = 16 * cm
|
||||
story.append(Image(str(img2), width=iw, height=iw * 0.55))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
story.append(p("2.1 Approach comparison", h2))
|
||||
approaches = [
|
||||
["Approach", "1× sender airtime?", "Notes"],
|
||||
["Unicast fan-out (today)", "No", "Same encode; N × send()."],
|
||||
["UDP multicast", "Yes on good LAN", "MulticastLock; AP client isolation risk."],
|
||||
["Sender server, N TCP clients", "No", "Flip connect direction; still N wire copies."],
|
||||
["LAN relay receiver", "Sender 1×", "Relay pays duplicate bandwidth."],
|
||||
]
|
||||
t2 = Table(approaches, colWidths=[4.5 * cm, 3.2 * cm, 8.8 * cm])
|
||||
t2.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), 9),
|
||||
("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), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t2)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Protocol notes:</b> CastFramingContext.sessionId and CastSessionGate already support "
|
||||
"session binding. FEC/NACK must become group-scoped (one retransmit cache, resend to multicast). "
|
||||
"Existing code paths: MultiCastCoordinator, CastFanoutPump, UdpCastTransport.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(PageBreak())
|
||||
|
||||
# --- Proposal 2 ---
|
||||
story.append(p("3. Proposal: multi-tier streams (e.g. full + 720p + 480p)", h1))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Goal:</b> Several encoded renditions from one capture; each receiver picks a tier at handshake "
|
||||
"(e.g. full quality vs 480p for bandwidth-limited devices).",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Feasible</b> via multiple encoders fed from one capture (EGL / scaled Surfaces into separate "
|
||||
"MediaCodec instances). Cost scales with <b>active</b> tiers (~2–3 HW encoders typical phone limit; "
|
||||
"3× software VP9 is heavy). Audio usually stays one AAC stream.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Bandwidth:</b> Unicast per tier → egress ≈ sum of each peer’s tier bitrate. "
|
||||
"Multicast per tier → one group per tier; airtime ≈ sum of tiers with ≥1 subscriber.",
|
||||
body,
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
story.append(p("3.1 Tier architecture options", h2))
|
||||
tiers = [
|
||||
["Option", "Verdict"],
|
||||
["Multiple HW encoders from one capture", "Recommended v1."],
|
||||
["One encode + CPU re-encode", "Too expensive for live cast."],
|
||||
["SVC / simulcast in one bitstream", "Poor Android HW support; defer."],
|
||||
["Receiver-only downscale", "Saves sender encode, not downlink bandwidth."],
|
||||
]
|
||||
t3 = Table(tiers, colWidths=[6 * cm, 10.5 * cm])
|
||||
t3.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), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t3)
|
||||
story.append(PageBreak())
|
||||
|
||||
# --- Rollout ---
|
||||
story.append(p("4. Recommended rollout", h1))
|
||||
rollout = [
|
||||
["Phase", "Deliverable", "Benefit"],
|
||||
[
|
||||
"A — Tier selection (unicast)",
|
||||
"Handshake tier; 720p + 480p encoders; send chosen tier per peer",
|
||||
"Per-device quality without multicast; no AP surprises",
|
||||
],
|
||||
[
|
||||
"B — Multicast one tier",
|
||||
"UDP multicast publish after unicast handshake",
|
||||
"~(N−1) egress for same-quality 1:N",
|
||||
],
|
||||
[
|
||||
"C — Multicast per tier",
|
||||
"Separate multicast group per tier; lazy encoder start",
|
||||
"Combines (1) and (2) on good LANs",
|
||||
],
|
||||
]
|
||||
t4 = Table(rollout, colWidths=[3.5 * cm, 7.5 * cm, 5.5 * cm])
|
||||
t4.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), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t4)
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
story.append(p("4.1 Summary judgment", h2))
|
||||
summary = [
|
||||
["Idea", "Feasible?", "Main win", "Main cost"],
|
||||
[
|
||||
"(1) Multicast + handshake gate",
|
||||
"Yes (UDP)",
|
||||
"~(N−1) uplink on good LAN",
|
||||
"Transport refactor; FEC/NACK; AP testing",
|
||||
],
|
||||
["(1) Unicast only", "Trivial", "Auth only", "No bandwidth win"],
|
||||
["(2) Multi-tier encode", "Yes", "Per-receiver quality", "2–3× encode resources"],
|
||||
["(1) + (2) combined", "Yes", "Multicast per tier", "Highest complexity"],
|
||||
]
|
||||
t5 = Table(summary, colWidths=[4.2 * cm, 2.2 * cm, 4.5 * cm, 4.6 * cm])
|
||||
t5.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), 8),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t5)
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Key code paths today:</b> CastConfig.MAX_RECEIVERS, MultiCastCoordinator, "
|
||||
"CastFanoutPump, ScreenCastService (primary peer), UdpCastTransport, CastFramingContext, "
|
||||
"CastSessionGate.",
|
||||
small,
|
||||
)
|
||||
)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
str(OUT_PDF),
|
||||
pagesize=A4,
|
||||
leftMargin=1.8 * cm,
|
||||
rightMargin=1.8 * cm,
|
||||
topMargin=1.5 * cm,
|
||||
bottomMargin=1.5 * cm,
|
||||
title="Android Cast — Next-gen egress review",
|
||||
author="Android Cast project",
|
||||
)
|
||||
doc.build(story, canvasmaker=NumberedCanvas)
|
||||
print(f"Wrote {OUT_PDF}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
render_mermaid()
|
||||
build_pdf()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
342
_pdf_build/build_git_flow_pdf.py
Normal file
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build docs/GIT_FLOW.pdf from structured content and Mermaid diagrams.
|
||||
|
||||
Diagrams: Mermaid CLI renders PNG via headless Chromium (text + layout correct).
|
||||
Do NOT pipe Mermaid SVG through rsvg-convert — Mermaid uses foreignObject HTML labels
|
||||
that librsvg drops (boxes only, no text).
|
||||
|
||||
Requires: reportlab, npx (@mermaid-js/mermaid-cli), Chromium (puppeteer bundled).
|
||||
Regenerate: python3 docs/_pdf_build/build_git_flow_pdf.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
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.lib.utils import ImageReader
|
||||
from reportlab.platypus import (
|
||||
Image,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
from page_footer import NumberedCanvas
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BUILD = Path(__file__).resolve().parent
|
||||
CONFIG = BUILD / "mermaid-config.json"
|
||||
OUT_PDF = ROOT / "docs" / "GIT_FLOW.pdf"
|
||||
|
||||
# (mmd, png, caption, mermaid width px, height px, scale, max width cm in PDF)
|
||||
DIAGRAMS = [
|
||||
("git_flow_diagram_branches.mmd", "git_flow_diagram_branches.png",
|
||||
"Branch model (green master)", 1800, 900, 2, 15.5),
|
||||
("git_flow_diagram_feature.mmd", "git_flow_diagram_feature.png",
|
||||
"Daily development", 2400, 2000, 2, 14.0),
|
||||
("git_flow_diagram_release.mmd", "git_flow_diagram_release.png",
|
||||
"Release: next → master", 2400, 2000, 2, 14.0),
|
||||
("git_flow_diagram_hotfix.mmd", "git_flow_diagram_hotfix.png",
|
||||
"Hotfix decision and flow", 1300, 1100, 2, 14.5),
|
||||
]
|
||||
|
||||
MAX_DIAGRAM_HEIGHT_CM = 16.5
|
||||
|
||||
DOC_VERSION = "1.3"
|
||||
DOC_DATE = "2026-05-23"
|
||||
DOC_AUTHOR = "Anton Afanaasyeu"
|
||||
DOC_SOURCE = "docs/GIT_FLOW.md"
|
||||
|
||||
|
||||
def render_mermaid() -> None:
|
||||
if not CONFIG.exists():
|
||||
raise FileNotFoundError(CONFIG)
|
||||
for mmd, png_name, _, w_px, h_px, scale, _ in DIAGRAMS:
|
||||
src = BUILD / mmd
|
||||
dst = BUILD / png_name
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(src)
|
||||
subprocess.run(
|
||||
[
|
||||
"npx", "--yes", "@mermaid-js/mermaid-cli@11.4.0",
|
||||
"-i", str(src),
|
||||
"-o", str(dst),
|
||||
"-c", str(CONFIG),
|
||||
"-b", "white",
|
||||
"-w", str(w_px),
|
||||
"-H", str(h_px),
|
||||
"-s", str(scale),
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
|
||||
def diagram_size_cm(png_path: Path, max_width_cm: float) -> tuple[float, float]:
|
||||
w_px, h_px = ImageReader(str(png_path)).getSize()
|
||||
max_w = max_width_cm * cm
|
||||
max_h = MAX_DIAGRAM_HEIGHT_CM * cm
|
||||
scale = min(max_w / w_px, max_h / h_px)
|
||||
return w_px * scale, h_px * scale
|
||||
|
||||
|
||||
def p(text: str, style) -> Paragraph:
|
||||
return Paragraph(text.replace("\n", "<br/>"), style)
|
||||
|
||||
|
||||
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
|
||||
data = [headers] + rows
|
||||
t = Table(data, colWidths=col_widths)
|
||||
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), 9),
|
||||
("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), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t)
|
||||
|
||||
|
||||
def add_diagram(story, png_name: str, caption: str, h2, max_width_cm: float) -> None:
|
||||
path = BUILD / png_name
|
||||
if not path.exists():
|
||||
return
|
||||
iw, ih = diagram_size_cm(path, max_width_cm)
|
||||
story.append(p(caption, h2))
|
||||
story.append(Image(str(path), width=iw, height=ih))
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
|
||||
|
||||
def build_pdf() -> None:
|
||||
styles = getSampleStyleSheet()
|
||||
title = ParagraphStyle(
|
||||
"DocTitle", parent=styles["Title"], fontSize=18, spaceAfter=10,
|
||||
textColor=colors.HexColor("#1565C0"),
|
||||
)
|
||||
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=12, spaceAfter=6)
|
||||
h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=11, spaceBefore=8, spaceAfter=4)
|
||||
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
|
||||
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
|
||||
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=14, bulletIndent=6)
|
||||
|
||||
story: list = []
|
||||
story.append(p("Android Cast — Git flow (green master)", title))
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
add_table(
|
||||
story,
|
||||
["Field", "Value"],
|
||||
[
|
||||
["Version", DOC_VERSION],
|
||||
["Published", DOC_DATE],
|
||||
["Author(s)", DOC_AUTHOR],
|
||||
["Markdown source", DOC_SOURCE],
|
||||
["Diagrams", "Mermaid CLI → PNG (Chromium); proportional embed in PDF"],
|
||||
],
|
||||
[4.0 * cm, 12.5 * cm],
|
||||
)
|
||||
story.append(Spacer(1, 0.25 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"Diagrams are rendered by headless Chromium (not rsvg/SVG rasterize) so labels stay visible. "
|
||||
"Regenerate after editing docs/_pdf_build/git_flow_diagram_*.mmd.",
|
||||
small,
|
||||
),
|
||||
)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
story.append(p("1. Goals", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Goal", "Mechanism"],
|
||||
[
|
||||
["Always shippable master", "Merge to master only when CI/tests pass"],
|
||||
["Safe integration", "Features land on next first"],
|
||||
["Clear production line", "Release tags only on master"],
|
||||
["Fast production patches", "hotfix/* from master, then sync to next"],
|
||||
],
|
||||
[4.5 * cm, 12 * cm],
|
||||
)
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
story.append(p("2. Branches", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Branch", "Role", "Direct commits?"],
|
||||
[
|
||||
["master", "Production; green; annotated tags vX.Y.Z", "No — merge only"],
|
||||
["next", "Integration / release candidate", "No — merge only"],
|
||||
["feature/*, fix/*", "Short-lived work", "Yes; merge → next"],
|
||||
["hotfix/*", "Urgent fix for released code", "Yes; merge → master first"],
|
||||
],
|
||||
[3 * cm, 8.5 * cm, 4 * cm],
|
||||
)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
add_diagram(story, DIAGRAMS[0][1], DIAGRAMS[0][2], h2, DIAGRAMS[0][6])
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(p("3. Daily development", h1))
|
||||
for line in [
|
||||
"git fetch && git checkout next && git pull",
|
||||
"git checkout -b feature/short-description",
|
||||
"Commit on topic branch; run tests before merge",
|
||||
"Merge topic → next (after CI green)",
|
||||
]:
|
||||
story.append(p(f"• {line}", bullet))
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Topic branch cleanup:</b> optional locally. On the server, enable "
|
||||
"<i>delete branch on merge</i> (Gitea/GitLab/GitHub) so merged feature/* "
|
||||
"branches are removed automatically on origin.",
|
||||
body,
|
||||
),
|
||||
)
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Agents:</b> never push directly to master or next unless the user explicitly "
|
||||
"requests a release or hotfix merge.",
|
||||
body,
|
||||
),
|
||||
)
|
||||
add_diagram(story, DIAGRAMS[1][1], DIAGRAMS[1][2], h2, DIAGRAMS[1][6])
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(p("4. Release (next → master)", h1))
|
||||
for line in [
|
||||
"Confirm next is QA-ready and CI green",
|
||||
"Merge next → master",
|
||||
"Smoke / CI on master",
|
||||
"Tag on master: git tag -a v0.1.2 -m \"Release v0.1.2\"",
|
||||
"Sync: git checkout next && git merge master && git push",
|
||||
"Continue feature branches from updated next",
|
||||
]:
|
||||
story.append(p(f"• {line}", bullet))
|
||||
add_diagram(story, DIAGRAMS[2][1], DIAGRAMS[2][2], h2, DIAGRAMS[2][6])
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(p("5. Hotfixes — recommended strategy", h1))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Default (option 1):</b> branch hotfix/* from <b>master</b> when the bug affects "
|
||||
"released code. After fix and tests: merge → master, tag patch (e.g. v0.1.3), then "
|
||||
"<b>merge master → next</b> so integration does not miss the fix.",
|
||||
body,
|
||||
),
|
||||
)
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Exception (option 2):</b> bug exists only on next — branch fix/* from next, "
|
||||
"merge to next only; ships with the next normal release.",
|
||||
body,
|
||||
),
|
||||
)
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Why not hotfix only on next?</b> next often contains unreleased features; "
|
||||
"merging next to master for a patch would ship extra code or block the fix.",
|
||||
body,
|
||||
),
|
||||
)
|
||||
add_diagram(story, DIAGRAMS[3][1], DIAGRAMS[3][2], h2, DIAGRAMS[3][6])
|
||||
|
||||
story.append(p("5.1 Sync cheat sheet", h2))
|
||||
add_table(
|
||||
story,
|
||||
["Event", "Action"],
|
||||
[
|
||||
["After release + tag", "merge master → next"],
|
||||
["After hotfix + tag", "merge master → next"],
|
||||
["Feature done", "merge topic → next only"],
|
||||
["Wrong base branch", "rebase topic onto latest next"],
|
||||
],
|
||||
[4.5 * cm, 12 * cm],
|
||||
)
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(p("6. Branch protection and CI", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Rule", "master", "next"],
|
||||
[
|
||||
["CI required before merge", "yes", "yes"],
|
||||
["No WIP direct push", "yes", "yes"],
|
||||
["Annotated release tags", "yes", "no"],
|
||||
["Force-push", "never", "maintainer only, rare"],
|
||||
["Delete branch on merge", "n/a", "yes (topic branches)"],
|
||||
],
|
||||
[5.5 * cm, 5 * cm, 5 * cm],
|
||||
)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(
|
||||
p(
|
||||
"<b>Force-push on next:</b> keep disabled by default. Allow only when you intentionally "
|
||||
"rewrite integration history — never on master. Prefer fixing on topic branches before "
|
||||
"merging to next.",
|
||||
body,
|
||||
),
|
||||
)
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("7. Cursor / agent checklist", h1))
|
||||
for line in [
|
||||
"Branch from next for features and non-production fixes",
|
||||
"Run tests before recommending merge to next",
|
||||
"Releases and tags only with explicit user approval",
|
||||
"Hotfix released code from master; sync master into next after tag",
|
||||
"Never force-push master; avoid force-push on next unless user requests",
|
||||
]:
|
||||
story.append(p(f"• {line}", bullet))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
story.append(
|
||||
p("Regenerate: python3 docs/_pdf_build/build_git_flow_pdf.py", small),
|
||||
)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
str(OUT_PDF),
|
||||
pagesize=A4,
|
||||
leftMargin=2 * cm,
|
||||
rightMargin=2 * cm,
|
||||
topMargin=2 * cm,
|
||||
bottomMargin=2 * cm,
|
||||
)
|
||||
doc.build(story, canvasmaker=NumberedCanvas)
|
||||
print(f"Wrote {OUT_PDF}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
render_mermaid()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Mermaid render failed:", e, file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
build_pdf()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
216
_pdf_build/build_graphvis_pivot_pdf.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build docs/GRAFANA_vs_others_graphvis_pivot.pdf decision memo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
from page_footer import NumberedCanvas
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT_PDF = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.pdf"
|
||||
SOURCE_MD = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.md"
|
||||
|
||||
|
||||
def p(text: str, style) -> Paragraph:
|
||||
return Paragraph(text.replace("\n", "<br/>"), style)
|
||||
|
||||
|
||||
def _cell(text: str, style: ParagraphStyle) -> Paragraph:
|
||||
safe = (text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
return Paragraph(safe.replace("\n", "<br/>"), style)
|
||||
|
||||
|
||||
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
|
||||
styles = getSampleStyleSheet()
|
||||
head = ParagraphStyle("TblHead", parent=styles["Normal"], fontName="Helvetica-Bold", fontSize=9, leading=11)
|
||||
body = ParagraphStyle("TblBody", parent=styles["Normal"], fontSize=9, leading=11)
|
||||
data = [[_cell(h, head) for h in headers]]
|
||||
for row in rows:
|
||||
data.append([_cell(c, body) for c in row])
|
||||
t = Table(data, colWidths=col_widths)
|
||||
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), 9),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("WORDWRAP", (0, 0), (-1, -1), "CJK"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(t)
|
||||
|
||||
|
||||
def build_pdf() -> None:
|
||||
styles = getSampleStyleSheet()
|
||||
title = ParagraphStyle(
|
||||
"DocTitle",
|
||||
parent=styles["Title"],
|
||||
fontSize=18,
|
||||
spaceAfter=10,
|
||||
textColor=colors.HexColor("#1565C0"),
|
||||
)
|
||||
h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, spaceBefore=12, spaceAfter=6)
|
||||
body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=10, leading=14, alignment=TA_LEFT)
|
||||
small = ParagraphStyle("Small", parent=body, fontSize=9, textColor=colors.grey)
|
||||
bullet = ParagraphStyle("Bullet", parent=body, leftIndent=14, bulletIndent=6)
|
||||
|
||||
story: list = []
|
||||
story.append(p("Android Cast — Graph Visualization Pivot", title))
|
||||
story.append(p("Scope: LAMP + nginx backend, no data duplication pipeline, role-based analytics.", small))
|
||||
story.append(Spacer(1, 0.25 * cm))
|
||||
|
||||
add_table(
|
||||
story,
|
||||
["Field", "Value"],
|
||||
[
|
||||
["Date", "2026-06-02"],
|
||||
["Context", "apps.f0xx.org + BE Alpine (nginx + php-fpm + MariaDB)"],
|
||||
["Constraint", "No cron ETL/data copy/pipeline complexity"],
|
||||
["Output", "Decision memo: Grafana vs custom PHP dashboards"],
|
||||
["Markdown source", "docs/GRAFANA_vs_others_graphvis_pivot.md"],
|
||||
],
|
||||
[4.2 * cm, 12.2 * cm],
|
||||
)
|
||||
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("1. Executive summary", h1))
|
||||
story.append(
|
||||
p(
|
||||
"• Grafana is viable only if it can query MariaDB directly (or via native SQL datasource) with strict RBAC mapping and SSO/session bridging.<br/>"
|
||||
"• If direct DB + auth integration becomes complex, custom PHP dashboards are lower risk and fit your stack with zero infra additions.<br/>"
|
||||
"• Recommended path: <b>hybrid</b> — build canonical SQL views + metrics contract once; start with custom PHP pages, optionally add Grafana later reusing same SQL.",
|
||||
bullet,
|
||||
)
|
||||
)
|
||||
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("2. Option comparison", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Option", "Pros", "Cons / Risks", "Stack impact"],
|
||||
[
|
||||
[
|
||||
"Grafana (direct DB)",
|
||||
"Fast charting, polished visuals, alerting, flexible panels",
|
||||
"Session/RBAC integration effort; slug scoping must be enforced in queries; additional service to operate",
|
||||
"Adds Grafana service only (no ETL required)",
|
||||
],
|
||||
[
|
||||
"Custom PHP + nginx",
|
||||
"Native app auth/session reuse; direct BE logic access; deterministic tenant filtering",
|
||||
"More frontend/dev work for visual polish; chart UX must be implemented",
|
||||
"No new runtime dependencies",
|
||||
],
|
||||
[
|
||||
"Hybrid (recommended)",
|
||||
"Ship fast with PHP now, preserve Grafana option later; one metrics SQL contract",
|
||||
"Needs discipline in shared metrics layer",
|
||||
"Minimal immediate changes",
|
||||
],
|
||||
],
|
||||
[3.1 * cm, 4.4 * cm, 5.0 * cm, 3.9 * cm],
|
||||
)
|
||||
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("3. Role-based graph pack (suggested)", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Role", "Core dashboards", "Example charts"],
|
||||
[
|
||||
[
|
||||
"User",
|
||||
"Own slug, own devices/sessions",
|
||||
"DAU devices, avg cast duration, avg recv sessions/day, success rate, app version split",
|
||||
],
|
||||
[
|
||||
"Slug admin",
|
||||
"All slug analytics + health",
|
||||
"Active/passive users, bandwidth send/recv 1d/7d, install source pie (Play/OTA/custom), NTP source usage and correction savings",
|
||||
],
|
||||
[
|
||||
"Platform admin",
|
||||
"Cross-slug global + reliability",
|
||||
"Crashes per slug/device/user, trend by app version/SDK, links to ticket IDs/issues, top regressions by fingerprint",
|
||||
],
|
||||
],
|
||||
[2.7 * cm, 5.1 * cm, 8.6 * cm],
|
||||
)
|
||||
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("4. Effort and cost estimate (engineering)", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Track", "Initial delivery", "Hardening", "Total estimate"],
|
||||
[
|
||||
["Grafana direct DB", "4-7 dev days", "5-8 dev days", "9-15 dev days"],
|
||||
["Custom PHP dashboards", "5-9 dev days", "3-6 dev days", "8-15 dev days"],
|
||||
["Hybrid phase-1 PHP then Grafana", "6-10 dev days", "optional +4-7 days", "10-17 dev days"],
|
||||
],
|
||||
[4.3 * cm, 3.8 * cm, 3.8 * cm, 4.5 * cm],
|
||||
)
|
||||
story.append(
|
||||
p(
|
||||
"Assumptions: existing MariaDB schema with crash/ticket/session tables, role data available; excludes major schema migration.",
|
||||
small,
|
||||
)
|
||||
)
|
||||
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("5. Recommendation under your constraints", h1))
|
||||
story.append(
|
||||
p(
|
||||
"• Do not introduce ETL/duplication. Keep one source of truth: MariaDB + live SQL views.<br/>"
|
||||
"• Implement a metrics SQL layer now (views/materialized semantics via SQL only, no copied store).<br/>"
|
||||
"• Build custom PHP dashboard pages first for guaranteed auth/session + slug scoping correctness.<br/>"
|
||||
"• Re-evaluate Grafana after metrics contract stabilizes; adopt only if direct DB + RBAC mapping is clean.",
|
||||
bullet,
|
||||
)
|
||||
)
|
||||
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
story.append(p("6. Proposed next implementation slice", h1))
|
||||
add_table(
|
||||
story,
|
||||
["Step", "Deliverable", "ETA"],
|
||||
[
|
||||
["1", "Define metrics dictionary + SQL views for user/slug/admin scopes", "1-2 days"],
|
||||
["2", "Add heartbeat metric: time source in use + ntp correction savings counters", "0.5-1 day"],
|
||||
["3", "Create PHP dashboards: user and slug-admin pages with 1d/7d selectors", "3-5 days"],
|
||||
["4", "Add admin reliability board + crash/ticket links", "2-3 days"],
|
||||
["5", "Optional Grafana POC over same SQL views", "2-3 days"],
|
||||
],
|
||||
[1.2 * cm, 11.9 * cm, 3.3 * cm],
|
||||
)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
str(OUT_PDF),
|
||||
pagesize=A4,
|
||||
leftMargin=1.8 * cm,
|
||||
rightMargin=1.8 * cm,
|
||||
topMargin=1.6 * cm,
|
||||
bottomMargin=1.6 * cm,
|
||||
title="Grafana vs Others GraphVis Pivot",
|
||||
author="Android Cast project",
|
||||
)
|
||||
doc.build(story, canvasmaker=NumberedCanvas)
|
||||
print(f"Wrote {OUT_PDF}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_pdf()
|
||||
24
_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–3 + Appendix 1 (WG test) + Appendix 2 (WG production)",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_pdf()
|
||||
15
_pdf_build/diagram_current_arch.mmd
Normal file
@@ -0,0 +1,15 @@
|
||||
flowchart LR
|
||||
subgraph once [Once per cast]
|
||||
Cap[MediaProjection capture]
|
||||
Enc[Video and audio encoders]
|
||||
Q[CastFanoutPump queues]
|
||||
end
|
||||
subgraph perPeer [Per receiver today]
|
||||
S1[CastSession 1 UDP or TCP]
|
||||
S2[CastSession 2]
|
||||
SN[CastSession N]
|
||||
end
|
||||
Cap --> Enc --> Q
|
||||
Q -->|same encoded payload| S1
|
||||
Q --> S2
|
||||
Q --> SN
|
||||
BIN
_pdf_build/diagram_current_arch.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
9
_pdf_build/diagram_multicast_proposal.mmd
Normal file
@@ -0,0 +1,9 @@
|
||||
sequenceDiagram
|
||||
participant S as Sender
|
||||
participant R1 as Receiver 1
|
||||
participant R2 as Receiver N
|
||||
S->>R1: Unicast handshake PIN codec tier
|
||||
S->>R2: Unicast handshake
|
||||
Note over S: One encoder one sessionId
|
||||
S-->>R1: Multicast media packets
|
||||
S-->>R2: Same packets
|
||||
BIN
_pdf_build/diagram_multicast_proposal.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
16
_pdf_build/git_flow_diagram_branches.mmd
Normal file
@@ -0,0 +1,16 @@
|
||||
%% Branch model — render PNG via mermaid-cli (Chromium). Do not rasterize SVG with rsvg.
|
||||
flowchart TB
|
||||
M["master\n(always green, tagged releases)"]
|
||||
N["next\n(integration / release candidate)"]
|
||||
F["feature/* fix/*\n(fork next)"]
|
||||
H["hotfix/*\n(fork master)"]
|
||||
|
||||
F -->|"merge + CI green"| N
|
||||
N -->|"release: merge + CI green"| M
|
||||
H -->|"urgent patch + CI green"| M
|
||||
M -->|"sync after release or hotfix"| N
|
||||
|
||||
style M fill:#c8e6c9,stroke:#2e7d32
|
||||
style N fill:#bbdefb,stroke:#1565c0
|
||||
style F fill:#fff9c4,stroke:#f9a825
|
||||
style H fill:#ffccbc,stroke:#e64a19
|
||||
BIN
_pdf_build/git_flow_diagram_branches.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
14
_pdf_build/git_flow_diagram_feature.mmd
Normal file
@@ -0,0 +1,14 @@
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Dev as Developer or agent
|
||||
participant Topic as feature/* or fix/*
|
||||
participant Next as next
|
||||
participant CI as Tests / CI
|
||||
|
||||
Dev->>Next: fetch, checkout, pull
|
||||
Dev->>Topic: create branch from next
|
||||
Dev->>Topic: commits
|
||||
Dev->>CI: run tests
|
||||
CI-->>Dev: pass
|
||||
Dev->>Next: merge topic branch
|
||||
Note over Next: delete branch on merge<br/>(origin server setting)
|
||||
BIN
_pdf_build/git_flow_diagram_feature.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
21
_pdf_build/git_flow_diagram_hotfix.mmd
Normal file
@@ -0,0 +1,21 @@
|
||||
flowchart TD
|
||||
Start([Defect found])
|
||||
Q{Affects released code\non master / latest tag?}
|
||||
|
||||
Start --> Q
|
||||
Q -->|Yes| A[Branch hotfix/* from master]
|
||||
A --> B[Fix + tests]
|
||||
B --> C[Merge to master]
|
||||
C --> D[Tag patch on master e.g. v0.1.3]
|
||||
D --> E[Merge master into next]
|
||||
E --> End([Done])
|
||||
|
||||
Q -->|No, only on next| F[Branch fix/* from next]
|
||||
F --> G[Fix + tests]
|
||||
G --> H[Merge to next only]
|
||||
H --> End2([Ships with next release])
|
||||
|
||||
style C fill:#c8e6c9
|
||||
style D fill:#c8e6c9
|
||||
style E fill:#bbdefb
|
||||
style H fill:#bbdefb
|
||||
BIN
_pdf_build/git_flow_diagram_hotfix.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
16
_pdf_build/git_flow_diagram_release.mmd
Normal file
@@ -0,0 +1,16 @@
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Next as next
|
||||
participant Master as master
|
||||
participant CI as Tests / CI
|
||||
participant Tag as tag vX.Y.Z
|
||||
|
||||
Note over Next: feature complete,<br/>QA ok
|
||||
Next->>CI: full test suite
|
||||
CI-->>Next: green
|
||||
Next->>Master: merge next to master
|
||||
Master->>CI: smoke on master
|
||||
CI-->>Master: green
|
||||
Master->>Tag: annotated tag on master
|
||||
Master->>Next: merge master into next (sync)
|
||||
Note over Next: continue features<br/>from synced next
|
||||
BIN
_pdf_build/git_flow_diagram_release.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
48
_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
|
||||
393
_pdf_build/md_to_pdf.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""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
|
||||
from mermaid_render import (
|
||||
cached_mermaid_png,
|
||||
diagram_flowables,
|
||||
ensure_diagram_caption_style,
|
||||
)
|
||||
from page_footer import NumberedCanvas
|
||||
|
||||
|
||||
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))
|
||||
|
||||
# Optional first-page metadata table (doc template block)
|
||||
meta_start = next((i for i, ln in enumerate(lines) if ln.strip() == "<!-- doc-meta:start -->"), None)
|
||||
meta_end = next((i for i, ln in enumerate(lines) if ln.strip() == "<!-- doc-meta:end -->"), None)
|
||||
if meta_start is not None and meta_end is not None and meta_end > meta_start:
|
||||
meta_rows = [ln for ln in lines[meta_start + 1 : meta_end] if ln.strip().startswith("|")]
|
||||
if len(meta_rows) >= 2:
|
||||
headers = _parse_table_row(meta_rows[0])
|
||||
data = [[_cell(h, tbl_head) for h in headers]]
|
||||
for row_line in meta_rows[2:]: # skip separator row
|
||||
row = _parse_table_row(row_line)
|
||||
padded = row + [""] * (len(headers) - len(row))
|
||||
data.append([_cell(c, tbl_body) for c in padded[: len(headers)]])
|
||||
if data and len(headers) > 0:
|
||||
col_widths = [5.2 * cm, 11.0 * cm] if len(headers) == 2 else [(16.2 * cm) / len(headers)] * len(headers)
|
||||
meta_tbl = Table(data, colWidths=col_widths)
|
||||
meta_tbl.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), 8),
|
||||
("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), 4),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(meta_tbl)
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
|
||||
# Page 1: title + metadata, page 2: TOC
|
||||
story.append(PageBreak())
|
||||
|
||||
# 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_lang = ""
|
||||
code_lines: list[str] = []
|
||||
ensure_diagram_caption_style(styles)
|
||||
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() == "<!-- doc-meta:start -->":
|
||||
while i < len(lines) and lines[i].strip() != "<!-- doc-meta:end -->":
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
if line.strip() == "<!-- doc-meta:end -->":
|
||||
i += 1
|
||||
continue
|
||||
if line.strip() == "\\newpage":
|
||||
story.append(PageBreak())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if line.strip().startswith("```"):
|
||||
if in_code:
|
||||
block = "\n".join(code_lines)
|
||||
if code_lang == "mermaid" and block.strip():
|
||||
try:
|
||||
png = cached_mermaid_png(block)
|
||||
story.extend(diagram_flowables(png, styles))
|
||||
except Exception as exc:
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"<i>Diagram render failed ({_xml(str(exc))}) — source:</i>",
|
||||
small,
|
||||
)
|
||||
)
|
||||
story.append(Preformatted(block, code_style))
|
||||
story.append(Spacer(1, 0.08 * cm))
|
||||
else:
|
||||
story.append(Preformatted(block, code_style))
|
||||
story.append(Spacer(1, 0.08 * cm))
|
||||
code_lines = []
|
||||
code_lang = ""
|
||||
in_code = False
|
||||
else:
|
||||
in_code = True
|
||||
code_lang = line.strip()[3:].strip().lower()
|
||||
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, canvasmaker=NumberedCanvas)
|
||||
print(f"Wrote {out_pdf} ({len(headings)} TOC entries from markdown headings)")
|
||||
17
_pdf_build/mermaid-config.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"theme": "neutral",
|
||||
"flowchart": {
|
||||
"htmlLabels": true,
|
||||
"curve": "basis"
|
||||
},
|
||||
"sequence": {
|
||||
"diagramMarginX": 120,
|
||||
"diagramMarginY": 28,
|
||||
"actorMargin": 90,
|
||||
"noteMargin": 24,
|
||||
"messageMargin": 48,
|
||||
"boxMargin": 14,
|
||||
"mirrorActors": false,
|
||||
"useMaxWidth": false
|
||||
}
|
||||
}
|
||||
BIN
_pdf_build/mermaid_cache/07a5cd069b637cab.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
_pdf_build/mermaid_cache/1d2a0f76fd56e1a5.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
_pdf_build/mermaid_cache/2e7a43ec4681d483.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
_pdf_build/mermaid_cache/3111adc4a19bf320.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
_pdf_build/mermaid_cache/3635a4d16c6483fd.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
_pdf_build/mermaid_cache/3ae0480d9417495d.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
_pdf_build/mermaid_cache/4005e1350215307a.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
_pdf_build/mermaid_cache/422109ab8f08520c.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
_pdf_build/mermaid_cache/442b6b0bf19a42e4.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
_pdf_build/mermaid_cache/4ddef865fc76605b.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
_pdf_build/mermaid_cache/5b3bc1ebae04e9e3.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
_pdf_build/mermaid_cache/5f792bc6d58b6247.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
_pdf_build/mermaid_cache/643e5701705fe73b.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
_pdf_build/mermaid_cache/6834cde6ac76ee91.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
_pdf_build/mermaid_cache/6b48b07401f29810.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
_pdf_build/mermaid_cache/6c0ce3bda41cbc5c.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
_pdf_build/mermaid_cache/72f2d5009e30fff7.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
_pdf_build/mermaid_cache/762ecd5905111586.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
_pdf_build/mermaid_cache/772dd6e10b8bee6f.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
_pdf_build/mermaid_cache/848dab9b3f0700f3.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
_pdf_build/mermaid_cache/86860a4e5a7adfb5.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
_pdf_build/mermaid_cache/86ea81e731c4a36e.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
_pdf_build/mermaid_cache/871c8da412a26f04.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
_pdf_build/mermaid_cache/8f5c44a541439a6b.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
_pdf_build/mermaid_cache/8f6c011de94bc2cc.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
_pdf_build/mermaid_cache/9530e1fd74794771.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
_pdf_build/mermaid_cache/9dc05f22a02c2ed8.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
_pdf_build/mermaid_cache/a703f345d5ff9a7a.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
_pdf_build/mermaid_cache/a938cabaac901192.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
_pdf_build/mermaid_cache/ac6793a806d49755.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
_pdf_build/mermaid_cache/b553f1a498102c38.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
_pdf_build/mermaid_cache/b7fff07876e49449.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
_pdf_build/mermaid_cache/bf96f90e74518abd.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
_pdf_build/mermaid_cache/bfb3ad9b30a028b6.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
_pdf_build/mermaid_cache/c4b8d36d9c2c57fa.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
_pdf_build/mermaid_cache/c5f273b0c7563dce.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
_pdf_build/mermaid_cache/c86aef5484327e98.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
_pdf_build/mermaid_cache/c9229ee9eafa7aa0.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
_pdf_build/mermaid_cache/d005b99c6120eaa4.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
_pdf_build/mermaid_cache/e7fac18ab112f762.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
_pdf_build/mermaid_cache/ee4ad19524a148a9.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
145
_pdf_build/mermaid_render.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Render Mermaid diagram sources to PNG for PDF embedding (mermaid-cli + Chromium)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.platypus import Image, Paragraph, Spacer
|
||||
|
||||
BUILD_DIR = Path(__file__).resolve().parent
|
||||
CONFIG = BUILD_DIR / "mermaid-config.json"
|
||||
CACHE_DIR = BUILD_DIR / "mermaid_cache"
|
||||
MERMAID_CLI = ("npx", "--yes", "@mermaid-js/mermaid-cli@11.4.0")
|
||||
|
||||
MAX_DIAGRAM_WIDTH_CM = 15.8
|
||||
MAX_DIAGRAM_HEIGHT_CM = 17.0
|
||||
CAPTION_STYLE_NAME = "DiagramCaption"
|
||||
|
||||
|
||||
def _content_hash(content: str) -> str:
|
||||
return hashlib.sha256(content.strip().encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def estimate_mermaid_pixels(content: str) -> tuple[int, int, int]:
|
||||
"""Return (width_px, height_px, scale) heuristics for flowchart layout."""
|
||||
lines = [ln for ln in content.splitlines() if ln.strip() and not ln.strip().startswith("%%")]
|
||||
n_edges = sum(1 for ln in lines if "-->" in ln or "---" in ln or "-.->" in ln)
|
||||
n_nodes = sum(
|
||||
1
|
||||
for ln in lines
|
||||
if "[" in ln or "(" in ln or "{" in ln or (ln.strip() and "-->" not in ln and "subgraph" not in ln.lower())
|
||||
)
|
||||
n_sub = sum(1 for ln in lines if "subgraph" in ln.lower())
|
||||
lr = any("flowchart LR" in ln or "graph LR" in ln for ln in lines)
|
||||
|
||||
scale = 2
|
||||
if lr:
|
||||
width = 2200 + min(n_nodes, 12) * 120
|
||||
height = 650 + n_sub * 180 + min(n_edges, 10) * 30
|
||||
else:
|
||||
width = 1700 + min(n_sub, 4) * 200
|
||||
height = 650 + n_edges * 70 + n_sub * 220 + min(n_nodes, 14) * 25
|
||||
|
||||
width = int(min(max(width, 1200), 3600))
|
||||
height = int(min(max(height, 550), 3400))
|
||||
return width, height, scale
|
||||
|
||||
|
||||
def render_mermaid_to_png(content: str, dest: Path, *, width: int, height: int, scale: int) -> None:
|
||||
if not CONFIG.exists():
|
||||
raise FileNotFoundError(f"mermaid config missing: {CONFIG}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False, encoding="utf-8") as tmp:
|
||||
tmp.write(content.strip() + "\n")
|
||||
mmd_path = Path(tmp.name)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
*MERMAID_CLI,
|
||||
"-i",
|
||||
str(mmd_path),
|
||||
"-o",
|
||||
str(dest),
|
||||
"-c",
|
||||
str(CONFIG),
|
||||
"-b",
|
||||
"white",
|
||||
"-w",
|
||||
str(width),
|
||||
"-H",
|
||||
str(height),
|
||||
"-s",
|
||||
str(scale),
|
||||
],
|
||||
cwd=str(BUILD_DIR.parents[1]),
|
||||
check=True,
|
||||
timeout=180,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
msg = (exc.stderr or exc.stdout or str(exc)).strip()
|
||||
raise RuntimeError(f"mermaid-cli failed: {msg}") from exc
|
||||
finally:
|
||||
mmd_path.unlink(missing_ok=True)
|
||||
if not dest.is_file() or dest.stat().st_size == 0:
|
||||
raise RuntimeError(f"mermaid-cli produced no PNG: {dest}")
|
||||
|
||||
|
||||
def cached_mermaid_png(content: str) -> Path:
|
||||
"""Render or reuse cached PNG for Mermaid source."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key = _content_hash(content)
|
||||
png = CACHE_DIR / f"{key}.png"
|
||||
if png.is_file() and png.stat().st_size > 0:
|
||||
return png
|
||||
w, h, scale = estimate_mermaid_pixels(content)
|
||||
render_mermaid_to_png(content, png, width=w, height=h, scale=scale)
|
||||
return png
|
||||
|
||||
|
||||
def diagram_flowables(
|
||||
png_path: Path,
|
||||
styles,
|
||||
*,
|
||||
caption: str = "",
|
||||
max_width_cm: float = MAX_DIAGRAM_WIDTH_CM,
|
||||
max_height_cm: float = MAX_DIAGRAM_HEIGHT_CM,
|
||||
) -> list:
|
||||
"""ReportLab flowables: image (+ optional caption) sized to fit A4 body."""
|
||||
reader = ImageReader(str(png_path))
|
||||
w_px, h_px = reader.getSize()
|
||||
max_w = max_width_cm * cm
|
||||
max_h = max_height_cm * cm
|
||||
scale = min(max_w / w_px, max_h / h_px)
|
||||
img = Image(str(png_path), width=w_px * scale, height=h_px * scale)
|
||||
img.hAlign = "CENTER"
|
||||
out: list = [Spacer(1, 0.12 * cm), img, Spacer(1, 0.06 * cm)]
|
||||
if caption:
|
||||
cap = styles[CAPTION_STYLE_NAME] if CAPTION_STYLE_NAME in styles else styles["Normal"]
|
||||
out.insert(0, Spacer(1, 0.06 * cm))
|
||||
out.append(Paragraph(f"<i>{caption}</i>", cap))
|
||||
out.append(Spacer(1, 0.1 * cm))
|
||||
return out
|
||||
|
||||
|
||||
def ensure_diagram_caption_style(styles) -> None:
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib import colors
|
||||
|
||||
if CAPTION_STYLE_NAME not in styles:
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
CAPTION_STYLE_NAME,
|
||||
parent=styles["Normal"],
|
||||
fontSize=7,
|
||||
textColor=colors.grey,
|
||||
alignment=1,
|
||||
spaceAfter=4,
|
||||
)
|
||||
)
|
||||
36
_pdf_build/page_footer.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Shared PDF footer canvas: bottom-right `current / total` page counter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
|
||||
class NumberedCanvas(canvas.Canvas):
|
||||
"""Canvas that renders `N / total` footer on every page."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_page_states: list[dict] = []
|
||||
|
||||
def showPage(self) -> None:
|
||||
self._saved_page_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self) -> None:
|
||||
total = len(self._saved_page_states)
|
||||
for state in self._saved_page_states:
|
||||
self.__dict__.update(state)
|
||||
self._draw_page_footer(total)
|
||||
super().showPage()
|
||||
super().save()
|
||||
|
||||
def _draw_page_footer(self, total_pages: int) -> None:
|
||||
self.saveState()
|
||||
self.setFillColor(colors.HexColor("#BDBDBD"))
|
||||
self.setFont("Helvetica", 8)
|
||||
label = f"{self._pageNumber} / {total_pages}"
|
||||
x = self._pagesize[0] - 1.5 * 28.3465 # ~1.5 cm from right
|
||||
y = 0.9 * 28.3465 # ~0.9 cm from bottom
|
||||
self.drawRightString(x, y, label)
|
||||
self.restoreState()
|
||||
5
_pdf_build/requirements-pdf.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
reportlab>=4.0
|
||||
|
||||
# Diagrams in markdown (```mermaid blocks): npx @mermaid-js/mermaid-cli (uses bundled Chromium).
|
||||
# Do NOT use rsvg-convert on Mermaid SVG — HTML labels are dropped.
|
||||
# Example: npx --yes @mermaid-js/mermaid-cli@11.4.0 --version
|
||||