Manual.md komplett neu: hochdetaillierte Referenz + PDF + Build-Pipeline
Manual.md ersetzt das bisherige kurze bilinguale Handbuch durch eine hochpräzise Anleitung: Kapitel 0-4/6-7 zitieren die tatsächlichen Tooltip-/Hilfetexte der App wörtlich (aus ConnectView, den Einrichten-Wizard-Schritten, DevicesView, BackupListView, OverviewView), Kapitel 5 (Experte-Feldreferenz, 44 Menüs/173 Felder) ist automatisiert aus RouterOSSchemaCatalog.swift geparst statt von Hand übertragen, kann also nie vom tatsächlichen App-Verhalten abweichen. Fünf Mermaid-Diagramme (Architektur, Verbindungs-/Vertrauens-Ablauf, Wizard-Schrittfolge, Fokus-Modus-Zustände, Sicherung/Wiederherstellung) als Quelle unter Manual-assets/diagrams/*.mmd, vorgerendert nach PNG. Echte App-Screenshots sind nicht möglich (keine macOS-UI-Automatisierung verfügbar) — im Manual selbst offen benannt statt stillschweigend ausgelassen. Neues build-manual.py regeneriert die Experte-Referenztabellen (zwischen festen Markern in Manual.md, Handschrift-Kapitel bleiben unberührt), rendert geänderte Diagramme neu und exportiert Manual.pdf über Markdown→HTML→headless Chrome print-to-pdf. README.md verweist auf beides. Künftig bei jeder Manual-Aktualisierung erneut laufen lassen, damit PDF und Referenztabellen nicht vom Quellcode abdriften. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,12 @@
|
||||
graph LR
|
||||
User["macOS-Nutzer"] --> App["RouterOS Assistant"]
|
||||
App --> T1["Verbinden"]
|
||||
App --> T2["Einrichten (Wizard)"]
|
||||
App --> T3["Übersicht"]
|
||||
App --> T4["LAN-Scanner"]
|
||||
App --> T5["Experte"]
|
||||
App --> T6["Sicherungen"]
|
||||
App -->|"bevorzugt"| REST["REST-API (/rest, HTTPS)"]
|
||||
App -->|"Fallback + Backup/Update/Firmware"| SSH["SSH"]
|
||||
REST --> Router[("MikroTik RouterOS Router")]
|
||||
SSH --> Router
|
||||
@@ -0,0 +1,9 @@
|
||||
flowchart TD
|
||||
A["Jetzt sichern"] --> B["Backup-Datei lokal gespeichert\n(wählbarer Speicherort)"]
|
||||
B --> C{"Was tun?"}
|
||||
C -->|"Wiederherstellen"| D{"Gleiches Routermodell?"}
|
||||
D -->|"Ja"| E["Upload per SFTP"]
|
||||
E --> F["Router spielt Backup ein\n(Login wird automatisch erhalten)"]
|
||||
D -->|"Nein"| X["Abgelehnt — Schutz vor Brick"]
|
||||
C -->|"Werkseinstellungen"| G["Automatisches Backup vorher"]
|
||||
G --> H["Kompletter Reset auf Werkszustand\nRouter startet neu, Verbindung geht verloren"]
|
||||
@@ -0,0 +1,13 @@
|
||||
flowchart TD
|
||||
A["Host, Benutzername, Passwort eingeben"] --> B{"Verbindungsweg"}
|
||||
B -->|"REST verfügbar (bevorzugt)"| C["REST: TLS-Zertifikat prüfen"]
|
||||
B -->|"REST nicht verfügbar"| D["SSH: Host-Schlüssel prüfen"]
|
||||
C --> E{"Zertifikat schon bekannt?"}
|
||||
E -->|"Nein, erste Verbindung"| F["Dialog: Vertrauen und verbinden"]
|
||||
E -->|"Ja"| G["Verbunden"]
|
||||
F --> G
|
||||
D --> H{"Host-Schlüssel schon bekannt?"}
|
||||
H -->|"Nein"| I["Dialog: Vertrauen und verbinden"]
|
||||
H -->|"Ja"| G
|
||||
I --> G
|
||||
G --> J["SSH-Host-Schlüssel zusätzlich im Hintergrund geprüft — für Backup, Update-Check, Firmware-Update, die immer SSH brauchen"]
|
||||
@@ -0,0 +1,7 @@
|
||||
stateDiagram-v2
|
||||
[*] --> Normal
|
||||
Normal --> Fokussiert: Klick auf Knoten
|
||||
Fokussiert --> Fokussiert: Klick auf anderen Knoten\nder gleichen Kette (Fokus wechselt)
|
||||
Fokussiert --> Normal: X-Button im Popup
|
||||
Fokussiert --> Normal: Klick auf leere Fläche
|
||||
Fokussiert --> Normal: erneuter Klick auf denselben Knoten
|
||||
@@ -0,0 +1,8 @@
|
||||
flowchart LR
|
||||
M["Modus wählen:\nEinfach oder Experte"] --> W["WAN\n(Internetanschluss)"]
|
||||
W --> L["LAN\n(ein oder mehrere Netzwerke)"]
|
||||
L --> V["VLAN\n(optional, zusätzliche Netzwerke)"]
|
||||
V --> Wi["WLAN\n(nur falls erkannt)"]
|
||||
Wi --> F["Firewall-Grundschutz"]
|
||||
F --> R["Review / Apply\n(jeder Befehl sichtbar, Auto-Backup vorher)"]
|
||||
R -->|"Jetzt anwenden"| D["Fertig"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
Binary file not shown.
@@ -217,7 +217,10 @@ RouterOS/
|
||||
├── RouterOSAssistantTests/ — Unit-Tests (Command-Builder, CLI-Parser, ViewModels, Mock-Transport)
|
||||
├── project.yml — xcodegen-Projektdefinition (Quelle der Wahrheit, nicht das generierte .xcodeproj editieren)
|
||||
├── HANDOFF.md — detaillierter technischer Stand, alle gefundenen Bugs, offene Punkte
|
||||
└── CHATLOG.md — chronologischer Session-Verlauf
|
||||
├── CHATLOG.md — chronologischer Session-Verlauf
|
||||
├── Manual.md / Manual.pdf — Bedienungsanleitung (DE, Feldreferenz wörtlich aus RouterOSSchemaCatalog.swift extrahiert)
|
||||
├── Manual-assets/ — Mermaid-Diagramme fürs Manual (*.mmd Quellen unter diagrams/, gerenderte *.png)
|
||||
└── build-manual.py — regeneriert Manual.md-Feldreferenz + Diagramme + Manual.pdf (nach jeder Schema-/Manual-Änderung erneut laufen lassen)
|
||||
```
|
||||
|
||||
## Setup / Bauen
|
||||
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerates Manual.md's Experte-reference table + all Mermaid diagrams + Manual.pdf.
|
||||
|
||||
Run after any change to RouterOSSchemaCatalog.swift, to a Manual-assets/diagrams/*.mmd
|
||||
file, or to the hand-written prose in Manual.md itself — same "update docs after every
|
||||
live-tested milestone" habit as README.md, just automated for the parts that would
|
||||
otherwise drift from the app's actual source text.
|
||||
|
||||
Requires:
|
||||
- `pip install markdown-it-py linkify-it-py mdit-py-plugins` (HTML step)
|
||||
- `npx @mermaid-js/mermaid-cli` reachable (diagram rendering — auto-downloads on first use)
|
||||
- Google Chrome installed at the default macOS path (HTML -> PDF via headless print)
|
||||
|
||||
Usage: python3 build-manual.py
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SCHEMA_SWIFT = ROOT / "RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift"
|
||||
MANUAL_MD = ROOT / "Manual.md"
|
||||
MANUAL_PDF = ROOT / "Manual.pdf"
|
||||
ASSETS = ROOT / "Manual-assets"
|
||||
DIAGRAMS = ASSETS / "diagrams"
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
CATEGORY_NAMES = {
|
||||
"firewallFilter": "Firewall: Filter-Regeln",
|
||||
"firewallNat": "Firewall: NAT (Portweiterleitung etc.)",
|
||||
"firewallMangle": "Firewall: Mangle (Markierung/QoS-Vorbereitung)",
|
||||
"firewallRaw": "Firewall: Raw (vor Connection-Tracking)",
|
||||
"firewallAddressLists": "Firewall: Adress-Listen",
|
||||
"interfaces": "Interfaces (Bridge, VLAN, VPN-Tunnel...)",
|
||||
"ipAddressing": "IP-Adressierung & Dienste",
|
||||
"routing": "Routing",
|
||||
"vpn": "VPN-Server/Clients",
|
||||
"wireless": "WLAN / CAPsMAN",
|
||||
"queues": "Queues / Bandbreiten-Steuerung",
|
||||
"system": "System",
|
||||
"tools": "Werkzeuge & Überwachung",
|
||||
}
|
||||
|
||||
CATEGORY_ORDER = [
|
||||
"System",
|
||||
"Interfaces (Bridge, VLAN, VPN-Tunnel...)",
|
||||
"IP-Adressierung & Dienste",
|
||||
"Routing",
|
||||
"VPN-Server/Clients",
|
||||
"WLAN / CAPsMAN",
|
||||
"Firewall: Filter-Regeln",
|
||||
"Firewall: NAT (Portweiterleitung etc.)",
|
||||
"Firewall: Mangle (Markierung/QoS-Vorbereitung)",
|
||||
"Firewall: Raw (vor Connection-Tracking)",
|
||||
"Firewall: Adress-Listen",
|
||||
"Queues / Bandbreiten-Steuerung",
|
||||
"Werkzeuge & Überwachung",
|
||||
]
|
||||
|
||||
|
||||
# --- Swift schema parsing (RouterOSSchemaCatalog.swift -> structured menu list) ---
|
||||
|
||||
|
||||
def find_matching_paren(s, start):
|
||||
depth, i, in_string, escape = 0, start, False, False
|
||||
while i < len(s):
|
||||
c = s[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif c == "\\":
|
||||
escape = True
|
||||
elif c == '"':
|
||||
in_string = False
|
||||
else:
|
||||
if c == '"':
|
||||
in_string = True
|
||||
elif c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
raise ValueError("unbalanced parens starting at %d" % start)
|
||||
|
||||
|
||||
def read_string_literal(s, i):
|
||||
assert s[i] == '"'
|
||||
j, escape, out = i + 1, False, []
|
||||
while j < len(s):
|
||||
c = s[j]
|
||||
if escape:
|
||||
out.append({"\\": "\\", '"': '"', "n": "\n"}.get(c, c))
|
||||
escape = False
|
||||
elif c == "\\":
|
||||
escape = True
|
||||
elif c == '"':
|
||||
return "".join(out), j + 1
|
||||
else:
|
||||
out.append(c)
|
||||
j += 1
|
||||
raise ValueError("unterminated string at %d" % i)
|
||||
|
||||
|
||||
def extract_kwarg_string(call, key):
|
||||
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*", call)
|
||||
if not m or m.end() >= len(call) or call[m.end()] != '"':
|
||||
return None
|
||||
val, _ = read_string_literal(call, m.end())
|
||||
return val
|
||||
|
||||
|
||||
def extract_kwarg_bool(call, key):
|
||||
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*(true|false)", call)
|
||||
return m.group(1) == "true" if m else None
|
||||
|
||||
|
||||
def find_call_args_span(call, key):
|
||||
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*", call)
|
||||
if not m:
|
||||
return None
|
||||
i = m.end()
|
||||
depth, j, in_string, escape = 0, i, False, False
|
||||
while j < len(call):
|
||||
c = call[j]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif c == "\\":
|
||||
escape = True
|
||||
elif c == '"':
|
||||
in_string = False
|
||||
else:
|
||||
if c == '"':
|
||||
in_string = True
|
||||
elif c in "([":
|
||||
depth += 1
|
||||
elif c in ")]":
|
||||
if depth == 0:
|
||||
break
|
||||
depth -= 1
|
||||
elif c == "," and depth == 0:
|
||||
break
|
||||
j += 1
|
||||
return call[i:j].strip()
|
||||
|
||||
|
||||
def parse_kind(expr):
|
||||
expr = expr.strip()
|
||||
if expr in (".text", ".bool", ".int", ".duration", ".interfacePick"):
|
||||
return {"type": expr[1:]}
|
||||
if expr.startswith(".enumPick"):
|
||||
opts_span = find_call_args_span(expr, "options")
|
||||
opts = re.findall(r'"((?:[^"\\]|\\.)*)"', opts_span or "")
|
||||
return {"type": "enumPick", "options": [o.replace('\\"', '"') for o in opts]}
|
||||
if expr.startswith(".menuItemPick"):
|
||||
return {"type": "menuItemPick", "menuPath": extract_kwarg_string(expr, "menuPath")}
|
||||
return {"type": "raw"}
|
||||
|
||||
|
||||
def parse_fields(fields_text):
|
||||
fields = []
|
||||
for m in re.finditer(r"RouterOSFieldSchema\(", fields_text):
|
||||
start = m.end() - 1
|
||||
end = find_matching_paren(fields_text, start)
|
||||
call = fields_text[start + 1 : end]
|
||||
kind_raw = find_call_args_span(call, "kind")
|
||||
fields.append(
|
||||
{
|
||||
"key": extract_kwarg_string(call, "key"),
|
||||
"label": extract_kwarg_string(call, "label"),
|
||||
"kind": parse_kind(kind_raw) if kind_raw else {"type": "unknown"},
|
||||
"help": extract_kwarg_string(call, "help") or "",
|
||||
"defaultValue": extract_kwarg_string(call, "defaultValue"),
|
||||
"required": bool(extract_kwarg_bool(call, "required")),
|
||||
}
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def parse_schema(src):
|
||||
menus = []
|
||||
for m in re.finditer(r"RouterOSMenuSchema\(", src):
|
||||
start = m.end() - 1
|
||||
end = find_matching_paren(src, start)
|
||||
call = src[start + 1 : end]
|
||||
cat_match = re.search(r"category\s*:\s*\.(\w+)", call)
|
||||
fields_span = find_call_args_span(call, "fields")
|
||||
menus.append(
|
||||
{
|
||||
"menuPath": extract_kwarg_string(call, "menuPath"),
|
||||
"restPath": extract_kwarg_string(call, "restPath"),
|
||||
"category": CATEGORY_NAMES.get(cat_match.group(1)) if cat_match else None,
|
||||
"displayName": extract_kwarg_string(call, "displayName"),
|
||||
"summary": extract_kwarg_string(call, "summary") or "",
|
||||
"explanation": extract_kwarg_string(call, "explanation") or "",
|
||||
"warning": extract_kwarg_string(call, "warning"),
|
||||
"isSingleton": extract_kwarg_bool(call, "isSingleton") or False,
|
||||
"fields": parse_fields(fields_span) if fields_span else [],
|
||||
"generic": False,
|
||||
}
|
||||
)
|
||||
|
||||
for m in re.finditer(r"(?<![A-Za-z0-9_])generic\(", src):
|
||||
start = m.end() - 1
|
||||
end = find_matching_paren(src, start)
|
||||
call = src[start + 1 : end]
|
||||
args, depth, in_string, escape, cur = [], 0, False, False, ""
|
||||
for c in call:
|
||||
if in_string:
|
||||
cur += c
|
||||
if escape:
|
||||
escape = False
|
||||
elif c == "\\":
|
||||
escape = True
|
||||
elif c == '"':
|
||||
in_string = False
|
||||
else:
|
||||
if c == '"':
|
||||
in_string, cur = True, cur + c
|
||||
elif c in "([":
|
||||
depth, cur = depth + 1, cur + c
|
||||
elif c in ")]":
|
||||
depth, cur = depth - 1, cur + c
|
||||
elif c == "," and depth == 0:
|
||||
args.append(cur.strip())
|
||||
cur = ""
|
||||
else:
|
||||
cur += c
|
||||
if cur.strip():
|
||||
args.append(cur.strip())
|
||||
|
||||
def unquote(a):
|
||||
a = a.strip()
|
||||
return a[1:-1].replace('\\"', '"') if a.startswith('"') else a
|
||||
|
||||
positional = [a for a in args if not re.match(r"^\w+\s*:", a)]
|
||||
cat_match = re.match(r"\.(\w+)", positional[2]) if len(positional) > 2 else None
|
||||
warning = None
|
||||
for a in args:
|
||||
wm = re.match(r"warning\s*:\s*(.*)$", a, re.S)
|
||||
if wm:
|
||||
warning = unquote(wm.group(1))
|
||||
summary = unquote(positional[4]) if len(positional) > 4 else ""
|
||||
explanation = unquote(positional[5]) if len(positional) > 5 else ""
|
||||
menus.append(
|
||||
{
|
||||
"menuPath": unquote(positional[0]) if positional else None,
|
||||
"restPath": unquote(positional[1]) if len(positional) > 1 else None,
|
||||
"category": CATEGORY_NAMES.get(cat_match.group(1)) if cat_match else None,
|
||||
"displayName": unquote(positional[3]) if len(positional) > 3 else None,
|
||||
"summary": summary,
|
||||
"explanation": explanation or summary,
|
||||
"warning": warning,
|
||||
"isSingleton": False,
|
||||
"fields": [],
|
||||
"generic": True,
|
||||
}
|
||||
)
|
||||
|
||||
seen, ordered = set(), []
|
||||
for menu in menus:
|
||||
if not menu["menuPath"] or menu["menuPath"] in seen or not menu["category"]:
|
||||
continue
|
||||
seen.add(menu["menuPath"])
|
||||
ordered.append(menu)
|
||||
return ordered
|
||||
|
||||
|
||||
def kind_label(kind):
|
||||
t = kind.get("type")
|
||||
if t in ("text", "bool", "int", "duration", "interfacePick"):
|
||||
return {
|
||||
"text": "Text",
|
||||
"bool": "Ja/Nein",
|
||||
"int": "Zahl",
|
||||
"duration": "Zeitdauer (Tage/Std/Min/Sek, per Stepper)",
|
||||
"interfacePick": "Auswahl aus Live-Interface-Liste des Routers",
|
||||
}[t]
|
||||
if t == "enumPick":
|
||||
return "Auswahl (fest): " + ", ".join(f"`{o}`" for o in kind.get("options", []))
|
||||
if t == "menuItemPick":
|
||||
return f"Verweis auf bestehenden Eintrag unter `{kind.get('menuPath')}`"
|
||||
return "—"
|
||||
|
||||
|
||||
def esc(s):
|
||||
return "" if s is None else s.replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render_expert_reference(menus):
|
||||
by_cat = {}
|
||||
for m in menus:
|
||||
by_cat.setdefault(m["category"], []).append(m)
|
||||
|
||||
lines = []
|
||||
for cat in CATEGORY_ORDER:
|
||||
if cat not in by_cat:
|
||||
continue
|
||||
lines.append(f"### {cat}\n")
|
||||
for m in by_cat[cat]:
|
||||
note = " *(Einstellungsmenü — genau ein Eintrag, kein Anlegen/Löschen)*" if m["isSingleton"] else ""
|
||||
lines.append(f"#### {m['displayName']}{note}")
|
||||
lines.append(f"RouterOS-Menü: `{m['menuPath']}` · REST-Pfad: `{m['restPath']}`\n")
|
||||
if m["summary"]:
|
||||
lines.append(f"{m['summary']}\n")
|
||||
if m["explanation"] and m["explanation"] != m["summary"]:
|
||||
lines.append(f"{m['explanation']}\n")
|
||||
if m.get("warning"):
|
||||
lines.append(f"> ⚠️ **Achtung:** {m['warning']}\n")
|
||||
if m["generic"]:
|
||||
lines.append(
|
||||
"*Noch kein kuratiertes Formular — alle Felder erscheinen als freie "
|
||||
"Schlüssel/Wert-Paare (siehe „Eigener Menüpfad“).*\n"
|
||||
)
|
||||
elif m["fields"]:
|
||||
lines.append("| Feld | RouterOS-Parameter | Typ | Pflicht | Standard | Hilfetext |")
|
||||
lines.append("|---|---|---|---|---|---|")
|
||||
for f in m["fields"]:
|
||||
default = f"`{esc(f['defaultValue'])}`" if f["defaultValue"] else "—"
|
||||
lines.append(
|
||||
f"| {esc(f['label'])} | `{esc(f['key'])}` | {kind_label(f['kind'])} "
|
||||
f"| {'Ja' if f['required'] else 'Nein'} | {default} | {esc(f['help'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("*Keine kuratierten Felder — generischer Schlüssel/Wert-Zugriff.*\n")
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def update_expert_reference_section():
|
||||
src = SCHEMA_SWIFT.read_text(encoding="utf-8")
|
||||
menus = parse_schema(src)
|
||||
ref_md = render_expert_reference(menus)
|
||||
|
||||
manual = MANUAL_MD.read_text(encoding="utf-8")
|
||||
start_marker, end_marker = "<!-- EXPERT_REFERENCE_START -->", "<!-- EXPERT_REFERENCE_END -->"
|
||||
start = manual.index(start_marker) + len(start_marker)
|
||||
end = manual.index(end_marker)
|
||||
manual = manual[:start] + "\n\n" + ref_md + "\n\n" + manual[end:]
|
||||
MANUAL_MD.write_text(manual, encoding="utf-8")
|
||||
print(f"Expert reference updated: {len(menus)} menus, {sum(len(m['fields']) for m in menus)} fields")
|
||||
|
||||
|
||||
# --- Diagram rendering ---
|
||||
|
||||
|
||||
def render_diagrams():
|
||||
DIAGRAMS.mkdir(parents=True, exist_ok=True)
|
||||
for mmd in sorted(DIAGRAMS.glob("*.mmd")):
|
||||
png = ASSETS / f"{mmd.stem}.png"
|
||||
if png.exists() and png.stat().st_mtime > mmd.stat().st_mtime:
|
||||
continue
|
||||
subprocess.run(
|
||||
["npx", "-y", "@mermaid-js/mermaid-cli", "-i", str(mmd), "-o", str(png), "-b", "white", "-s", "2"],
|
||||
check=True,
|
||||
)
|
||||
print(f"rendered {png.name}")
|
||||
|
||||
|
||||
# --- Markdown -> HTML -> PDF ---
|
||||
|
||||
|
||||
def render_pdf():
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
md_text = MANUAL_MD.read_text(encoding="utf-8")
|
||||
md = MarkdownIt("gfm-like").enable("table")
|
||||
body_html = md.render(md_text)
|
||||
|
||||
def fix_img(m):
|
||||
prefix, path, suffix = m.group(1), m.group(2), m.group(3)
|
||||
if path.startswith(("http://", "https://", "file://", "/")):
|
||||
return m.group(0)
|
||||
return f'{prefix}file://{(ROOT / path).resolve()}{suffix}'
|
||||
|
||||
body_html = re.sub(r'(<img[^>]*src=")([^"]+)("[^>]*>)', fix_img, body_html)
|
||||
|
||||
html = f"""<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">
|
||||
<title>RouterOS Assistant — Bedienungsanleitung</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 18mm 16mm; }}
|
||||
body {{ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 10.5pt; line-height: 1.5; color: #1a1a1a; }}
|
||||
h1 {{ font-size: 20pt; border-bottom: 2px solid #2f6fb0; padding-bottom: 6px; }}
|
||||
h2 {{ font-size: 15pt; color: #2f6fb0; margin-top: 28px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }}
|
||||
h3 {{ font-size: 12.5pt; color: #1a4a75; margin-top: 20px; }}
|
||||
h4 {{ font-size: 11pt; margin-top: 14px; margin-bottom: 4px; }}
|
||||
code {{ background: #f2f2f2; padding: 1px 4px; border-radius: 3px; font-size: 92%; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin: 8px 0 16px 0; font-size: 9pt; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 4px 6px; text-align: left; vertical-align: top; }}
|
||||
th {{ background: #eef4fb; }}
|
||||
tr {{ page-break-inside: avoid; }}
|
||||
blockquote {{ border-left: 4px solid #d9a441; background: #fff8ea; margin: 10px 0; padding: 6px 12px; }}
|
||||
img {{ max-width: 90%; display: block; margin: 14px auto; }}
|
||||
a {{ color: #2f6fb0; }}
|
||||
hr {{ border: none; border-top: 1px solid #ddd; margin: 24px 0; }}
|
||||
</style></head><body>{body_html}</body></html>"""
|
||||
|
||||
html_path = ASSETS / "_manual_build.html"
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
|
||||
subprocess.run(
|
||||
[CHROME, "--headless", "--disable-gpu", "--no-pdf-header-footer",
|
||||
f"--print-to-pdf={MANUAL_PDF}", f"file://{html_path}"],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
html_path.unlink()
|
||||
print(f"PDF written: {MANUAL_PDF} ({MANUAL_PDF.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
def main():
|
||||
update_expert_reference_section()
|
||||
render_diagrams()
|
||||
render_pdf()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user