aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlavian Kaufmann <flavian@flaviankaufmann.ch>2025-08-13 17:21:00 +0200
committerFlavian Kaufmann <flavian@flaviankaufmann.ch>2025-08-13 17:21:00 +0200
commitd3a5f84342d479911070052607859fbd2ba78127 (patch)
tree2f5e83131cf7c6141dac18e6d0a42deb5453ef6b
parent8b841972b70341c80b1c71a61fbba1806257eb2b (diff)
downloadtltxch-master.tar.gz
tltxch-master.zip
2nd attemptHEADmaster
-rw-r--r--README.md29
-rw-r--r--res/ets_300706e01p.pdfbin0 -> 738407 bytes
-rw-r--r--res/example.pngbin294493 -> 110850 bytes
-rw-r--r--teletext-tui.py398
-rwxr-xr-xtltxch.py291
5 files changed, 310 insertions, 408 deletions
diff --git a/README.md b/README.md
index 690fdd2..052abe5 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,29 @@
-# Teletext TUI (Swiss Teletext Terminal User Interface)
+# tltxch (Swiss Teletext Terminal Client)
-Python program that fetches (Swiss) teletext from `api.teletext.ch` and displays
-it in the terminal using curses.
+Python program that fetches (Swiss) teletext from `api.teletext.ch` and displays it in the terminal.
+
+## References
+
+- [teletext.ch](https://teletext.ch)
+- API: `https://api.teletext.ch/channels/{channel}/pages/{page}`
+- [ETS 300 706](./res/ets_300706e01p.pdf)
## Usage
-- `c`: switch channel (srf1, srfzwei, srfinfo)
-- `<number><return>`: got to page
-- `h`/`l` go to prev/next page
-- `r` reload page
-- `q` quit
+```
+tltxch.py [--tui] [--channel CHANNEL] [--page PAGE]
+ --tui run in TUI mode.
+ --channel CHANNEL specify channel: srf1 (default), srfzwei, srfinfo.
+ --page page specify page: 100 (default)
+```
+In TUI mode:
+- `q`: quit
+- `n/p`: next/prev page
+- `g`: goto page
## Limitations
-- Graphics are not rendered.
-- Sometimes text is misaligned.
+- Graphics are not rendered properly.
## Example
diff --git a/res/ets_300706e01p.pdf b/res/ets_300706e01p.pdf
new file mode 100644
index 0000000..fdba224
--- /dev/null
+++ b/res/ets_300706e01p.pdf
Binary files differ
diff --git a/res/example.png b/res/example.png
index 2ed402a..9aecae1 100644
--- a/res/example.png
+++ b/res/example.png
Binary files differ
diff --git a/teletext-tui.py b/teletext-tui.py
deleted file mode 100644
index abe668d..0000000
--- a/teletext-tui.py
+++ /dev/null
@@ -1,398 +0,0 @@
-#!/usr/bin/env python3
-
-"""
-Teletext TUI for api.teletext.ch
-"""
-
-import argparse
-import base64
-import curses
-import textwrap
-import time
-from dataclasses import dataclass
-from typing import List, Optional, Tuple
-
-import requests
-
-API_BASE = "https://api.teletext.ch"
-CHANNELS_DEFAULT = ["srf1", "srfzwei", "srfinfo"]
-
-# ───────── Models ─────────
-
-
-@dataclass
-class Subpage:
- subpageNumber: int
- contentText: str
- ep1Info: Optional[dict] = None
-
-
-@dataclass
-class PageData:
- channel: str
- pageNumber: int
- nextPage: Optional[int]
- subpages: List[Subpage]
- previousPage: Optional[int] = None
-
-
-# ───────── Fetch ─────────
-
-
-def fetch_page(channel: str, page: int, timeout: float = 6.0) -> PageData:
- """Fetch a Teletext page JSON from the API."""
- url = f"{API_BASE}/channels/{channel}/pages/{page}"
- r = requests.get(url, timeout=timeout, headers={"Accept": "application/json"})
- r.raise_for_status()
-
- j = r.json()
- subs: List[Subpage] = [
- Subpage(
- subpageNumber=sp.get("subpageNumber", 0),
- contentText=sp.get("contentText") or "",
- ep1Info=sp.get("ep1Info"),
- )
- for sp in j.get("subpages", []) or []
- ]
-
- if not subs:
- subs = [Subpage(0, "", None)]
-
- return PageData(
- channel=j.get("channel", channel),
- pageNumber=j.get("pageNumber", page),
- nextPage=j.get("nextPage"),
- subpages=subs,
- previousPage=j.get("previousPage"),
- )
-
-
-# ───────── Teletext decode ─────────
-
-GERMAN_NATIONAL_MAP = {
- 0x5B: "Ä",
- 0x5C: "Ö",
- 0x5D: "Ü",
- 0x7B: "ä",
- 0x7C: "ö",
- 0x7D: "ü",
- 0x7E: "ß",
-}
-
-
-def alpha_char(b: int) -> str:
- """Map teletext character codes to Unicode."""
- return GERMAN_NATIONAL_MAP.get(b, chr(b))
-
-
-def ep1_rows_text_only(ep1_info: dict) -> List[List[Tuple[str, int, int, bool]]]:
- """Decode EP1-format data into rows of (text, fg, bg, bold) segments."""
- ep1 = ep1_info.get("data", {}).get("ep1Format", {})
- header_b = base64.b64decode(ep1.get("header", "") or b"")
- content_b = base64.b64decode(ep1.get("content", "") or b"")
-
- def render_row(bs: bytes) -> List[Tuple[str, int, int, bool]]:
- alpha = True
- fg, bg = 7, -1
- double = False
- segs: List[Tuple[str, int, int, bool]] = []
- buf: List[str] = []
-
- def flush():
- nonlocal buf
- if buf:
- segs.append(("".join(buf), fg, bg, double))
- buf = []
-
- i = 0
- while i < len(bs):
- b = bs[i]
- if b < 0x20:
- if 0x00 <= b <= 0x07:
- flush()
- fg = b
- alpha = True
- elif b == 0x0C:
- flush()
- double = False
- elif b == 0x0D:
- flush()
- double = True
- elif b == 0x0F:
- flush()
- alpha = False
- elif 0x10 <= b <= 0x17:
- flush()
- fg = b - 0x10
- alpha = False
- elif b == 0x1C:
- flush()
- bg = -1
- elif b == 0x1D:
- flush()
- bg = fg
- elif b == 0x1F:
- alpha = True
- i += 1
- continue
-
- buf.append(alpha_char(b) if alpha else " ")
- i += 1
-
- flush()
- total = sum(len(s) for s, _, _, _ in segs)
- if total < 40:
- segs.append((" " * (40 - total), fg, bg, double))
- elif total > 40:
- over = total - 40
- s, cf, cb, db = segs[-1]
- segs[-1] = (s[:-over], cf, cb, db)
- return segs
-
- rows = []
- if len(header_b) >= 40:
- rows.append(render_row(header_b[:40]))
- for i in range(0, len(content_b), 40):
- chunk = content_b[i : i + 40]
- if not chunk:
- break
- rows.append(render_row(chunk))
-
- def is_blank(row):
- return "".join(s for s, _, _, _ in row).strip() == ""
-
- while rows and is_blank(rows[-1]):
- rows.pop()
- return rows
-
-
-# ───────── UI helpers ─────────
-
-
-def wrap_plain(text: str, width: int) -> List[str]:
- """Wrap plain text into lines without breaking whitespace."""
- out = []
- for line in text.splitlines():
- out.extend(
- textwrap.wrap(
- line, width=width, replace_whitespace=False, drop_whitespace=False
- )
- or [""]
- )
- return out
-
-
-def draw_bar(stdscr, msg, row, width, pair=0):
- """Draw a single-line colored bar."""
- msg = (msg[: width - 1]).ljust(width - 1)
- if pair:
- stdscr.attron(curses.color_pair(pair))
- stdscr.addstr(row, 0, msg)
- if pair:
- stdscr.attroff(curses.color_pair(pair))
-
-
-# ───────── TUI ─────────
-
-
-def run_tui(initial_channel: str, initial_page: int, channels: List[str]):
- def main(stdscr):
- curses.curs_set(0)
- stdscr.nodelay(True)
- stdscr.timeout(100)
- curses.start_color()
- curses.use_default_colors()
-
- curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) # header
- curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE) # footer
- curses.init_pair(3, curses.COLOR_RED, -1) # error
-
- pair_base = 20
- pair_idx = {}
- palette = [
- curses.COLOR_BLACK,
- curses.COLOR_RED,
- curses.COLOR_GREEN,
- curses.COLOR_YELLOW,
- curses.COLOR_BLUE,
- curses.COLOR_MAGENTA,
- curses.COLOR_CYAN,
- curses.COLOR_WHITE,
- ]
-
- def pair_for(fg: int, bg: int) -> int:
- key = (fg, bg)
- if key in pair_idx:
- return pair_idx[key]
- pid = pair_base + len(pair_idx)
- curses.init_pair(
- pid,
- palette[max(0, min(7, fg))],
- -1 if bg == -1 else palette[max(0, min(7, bg))],
- )
- pair_idx[key] = pid
- return pid
-
- channel_idx = next(
- (i for i, c in enumerate(channels) if c == initial_channel), 0
- )
- channel = channels[channel_idx]
- page = initial_page
- sub_idx = 0
- input_buf = ""
- status = "Ready."
- last_status_at = time.time()
-
- try:
- pd = fetch_page(channel, page)
- status = f"Loaded {channel}:{page}"
- except Exception as e:
- pd = PageData(channel, page, None, [Subpage(0, f"Error: {e}", None)])
- status = f"Error loading {channel}:{page} – {e}"
-
- while True:
- stdscr.erase()
- h, w = stdscr.getmaxyx()
- if h < 8 or w < 42:
- stdscr.addstr(0, 0, "Please resize terminal (>= 42x8).")
- stdscr.refresh()
- time.sleep(0.1)
- continue
-
- header = (
- f" Teletext {pd.channel} Page {pd.pageNumber} "
- f"Sub {min(sub_idx + 1, len(pd.subpages))}/{max(1, len(pd.subpages))} "
- "[q] quit [0..9 Enter] goto [h/l] prev/next [c] channel [r] reload"
- )
- draw_bar(stdscr, header, 0, w, 1)
-
- body_h, body_w = h - 3, w
- sp = pd.subpages[min(sub_idx, len(pd.subpages) - 1)]
-
- if sp.contentText:
- for i, line in enumerate(wrap_plain(sp.contentText, body_w)[:body_h]):
- stdscr.addnstr(1 + i, 0, line, body_w - 1)
- elif sp.ep1Info:
- rows = ep1_rows_text_only(sp.ep1Info)
- r_i = 0
- for row in rows:
- if r_i >= body_h:
- break
- col = 0
- is_double = any(b for _, _, _, b in row)
- for txt, fg, bg, bold in row:
- if col >= body_w - 1:
- break
- chunk = txt[: max(0, body_w - 1 - col)]
- attrs = curses.color_pair(pair_for(fg, bg)) | (
- curses.A_BOLD if bold else 0
- )
- stdscr.addstr(1 + r_i, col, chunk, attrs)
- col += len(chunk)
- r_i += 1
- if is_double and r_i < body_h:
- stdscr.addstr(1 + r_i, 0, " " * min(40, body_w - 1))
- r_i += 1
- else:
- stdscr.addstr(1, 0, "(empty)")
-
- draw_bar(stdscr, f"Go to page: {input_buf}", h - 2, w, 2)
- if time.time() - last_status_at > 3:
- status = ""
- draw_bar(
- stdscr, status, h - 1, w, 0 if not status.startswith("Error") else 3
- )
-
- stdscr.refresh()
- ch = stdscr.getch()
- if ch == -1:
- continue
-
- if ch in (ord("q"), ord("Q")):
- break
- if ch in (ord("h"), curses.KEY_LEFT):
- if sub_idx > 0:
- sub_idx -= 1
- else:
- if isinstance(pd.previousPage, int):
- page = pd.previousPage
- sub_idx = 0
- try:
- pd = fetch_page(channel, page)
- status = f"Loaded {channel}:{page}"
- except Exception as e:
- status = f"Error loading {channel}:{page} – {e}"
- last_status_at = time.time()
- continue
- if ch in (ord("l"), curses.KEY_RIGHT):
- if sub_idx + 1 < len(pd.subpages):
- sub_idx += 1
- else:
- if isinstance(pd.nextPage, int):
- page = pd.nextPage
- sub_idx = 0
- try:
- pd = fetch_page(channel, page)
- status = f"Loaded {channel}:{page}"
- except Exception as e:
- status = f"Error loading {channel}:{page} – {e}"
- last_status_at = time.time()
- continue
- if ch in (ord("c"), ord("C")):
- channel_idx = (channel_idx + 1) % len(channels)
- channel = channels[channel_idx]
- sub_idx = 0
- try:
- pd = fetch_page(channel, page)
- status = f"Channel {channel}"
- except Exception as e:
- status = f"Error loading {channel}:{page} – {e}"
- last_status_at = time.time()
- continue
- if ch in (ord("r"), ord("R")):
- try:
- pd = fetch_page(channel, page)
- sub_idx = min(sub_idx, len(pd.subpages) - 1)
- status = f"Reloaded {channel}:{page}"
- except Exception as e:
- status = f"Error reloading {channel}:{page} – {e}"
- last_status_at = time.time()
- continue
- if ch in (curses.KEY_BACKSPACE, 127, 8):
- input_buf = input_buf[:-1]
- continue
- if ch in (curses.KEY_ENTER, 10, 13):
- if input_buf.strip().isdigit():
- page = int(input_buf)
- sub_idx = 0
- try:
- pd = fetch_page(channel, page)
- status = f"Loaded {channel}:{page}"
- except Exception as e:
- status = f"Error loading {channel}:{page} – {e}"
- last_status_at = time.time()
- input_buf = ""
- continue
- if 48 <= ch <= 57 and len(input_buf) < 4:
- input_buf += chr(ch)
-
- curses.wrapper(main)
-
-
-# ───────── CLI ─────────
-
-
-def parse_args():
- ap = argparse.ArgumentParser(description="Teletext TUI for api.teletext.ch")
- ap.add_argument("--channel", default="srf1")
- ap.add_argument("--page", type=int, default=100)
- return ap.parse_args()
-
-
-def main():
- args = parse_args()
- run_tui(args.channel, args.page, CHANNELS_DEFAULT)
-
-
-if __name__ == "__main__":
- main()
diff --git a/tltxch.py b/tltxch.py
new file mode 100755
index 0000000..702543c
--- /dev/null
+++ b/tltxch.py
@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+
+import json, base64, sys, argparse, curses
+from dataclasses import dataclass
+from urllib.request import urlopen, Request
+from urllib.error import URLError, HTTPError
+
+USE_SEXTANTS = False
+ROWS, COLS = 25, 40
+
+COLOR_MAP = {
+ "BLACK": curses.COLOR_BLACK,
+ "RED": curses.COLOR_RED,
+ "GREEN": curses.COLOR_GREEN,
+ "YELLOW": curses.COLOR_YELLOW,
+ "BLUE": curses.COLOR_BLUE,
+ "MAGENTA": curses.COLOR_MAGENTA,
+ "CYAN": curses.COLOR_CYAN,
+ "WHITE": curses.COLOR_WHITE,
+}
+
+ANSI_FG = {
+ "BLACK": "\033[30m",
+ "RED": "\033[31m",
+ "GREEN": "\033[32m",
+ "YELLOW": "\033[33m",
+ "BLUE": "\033[34m",
+ "MAGENTA": "\033[35m",
+ "CYAN": "\033[36m",
+ "WHITE": "\033[37m",
+}
+ANSI_BG = {
+ "BLACK": "\033[40m",
+ "RED": "\033[41m",
+ "GREEN": "\033[42m",
+ "YELLOW": "\033[43m",
+ "BLUE": "\033[44m",
+ "MAGENTA": "\033[45m",
+ "CYAN": "\033[46m",
+ "WHITE": "\033[47m",
+}
+ANSI_RESET = "\033[0m"
+
+G0_DE = {0x5B: "Ä", 0x5C: "Ö", 0x5D: "Ü", 0x7B: "ä", 0x7C: "ö", 0x7D: "ü", 0x7E: "ß"}
+
+
+@dataclass
+class RowState:
+ alpha: str = "WHITE"
+ mosaic: bool = False
+ flash: bool = False
+ box: bool = False
+ size: str = "NORMAL"
+ contiguous: bool = True
+ hold_mosaic: bool = False
+ conceal: bool = False
+ bg: str = "BLACK"
+ held_code: int = 0x20
+ held_contiguous: bool = True
+
+
+COLOUR_INDEX = ["BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE"]
+
+
+def is_set_after(d: int) -> bool:
+ return d in {
+ 0x00,
+ 0x01,
+ 0x02,
+ 0x03,
+ 0x04,
+ 0x05,
+ 0x06,
+ 0x07,
+ 0x08,
+ 0x0A,
+ 0x0B,
+ 0x0D,
+ 0x0E,
+ 0x0F,
+ 0x10,
+ 0x11,
+ 0x12,
+ 0x13,
+ 0x14,
+ 0x15,
+ 0x16,
+ 0x17,
+ 0x1B,
+ 0x1F,
+ }
+
+
+def apply_control(d: int, st: RowState):
+ if 0x00 <= d <= 0x07:
+ st.alpha = COLOUR_INDEX[d]
+ st.mosaic = False
+ st.held_code = 0x20
+ elif d == 0x08:
+ st.flash = True
+ elif d == 0x09:
+ st.flash = False
+ elif d == 0x0A:
+ st.box = False
+ elif d == 0x0B:
+ st.box = True
+ elif d == 0x0C:
+ if st.size != "NORMAL":
+ st.size = "NORMAL"
+ st.held_code = 0x20
+ elif d == 0x0D:
+ st.size = "DH"
+ st.held_code = 0x20
+ elif d == 0x0E:
+ st.size = "DW"
+ st.held_code = 0x20
+ elif d == 0x0F:
+ st.size = "DS"
+ st.held_code = 0x20
+ elif 0x10 <= d <= 0x17:
+ st.alpha = COLOUR_INDEX[d - 0x10]
+ st.mosaic = True
+ elif d == 0x18:
+ st.conceal = True
+ elif d == 0x19:
+ st.contiguous = True
+ elif d == 0x1A:
+ st.contiguous = False
+ elif d == 0x1B:
+ pass
+ elif d == 0x1C:
+ st.bg = "BLACK"
+ elif d == 0x1D:
+ st.bg = st.alpha
+ elif d == 0x1E:
+ st.hold_mosaic = True
+ elif d == 0x1F:
+ st.hold_mosaic = False
+ st.held_code = 0x20
+
+
+def g0_char(d: int, national="DE") -> str:
+ if national == "DE" and d in G0_DE:
+ return G0_DE[d]
+ if d == 0x7F:
+ return " "
+ return chr(d) if 0x20 <= d <= 0x7E else " "
+
+
+def mosaic_glyph(d: int, contiguous: bool) -> str:
+ if not USE_SEXTANTS:
+ return "#" if d != 0x20 else " "
+ return " " if d != 0x20 else " "
+
+
+def fetch_stream(channel: str, page: int):
+ url = f"https://api.teletext.ch/channels/{channel}/pages/{page}"
+ req = Request(url)
+ try:
+ with urlopen(req, timeout=10) as resp:
+ payload = json.loads(
+ resp.read().decode(resp.headers.get_content_charset() or "utf-8")
+ )
+ ep1 = payload["subpages"][0]["ep1Info"]["data"]["ep1Format"]
+ stream = (
+ base64.b64decode(ep1["header"])
+ + base64.b64decode(ep1["content"])
+ + base64.b64decode(ep1["commandRow"])
+ )
+ return stream, None
+ except (HTTPError, URLError) as e:
+ return None, f"Network error: {e}"
+ except Exception as e:
+ return None, f"Error: {e}"
+
+
+def decode_stream(bytestream: bytes):
+ """Yield (row, col, char, fg_color, bg_color) tuples."""
+ n = ROWS * COLS
+ if bytestream is None:
+ bytestream = bytes([0x20]) * n
+ elif len(bytestream) < n:
+ bytestream += bytes([0x20]) * (n - len(bytestream))
+ elif len(bytestream) > n:
+ bytestream = bytestream[:n]
+
+ idx = 0
+ for row in range(ROWS):
+ if idx == 0 or idx == (ROWS - 1) * COLS:
+ idx += COLS
+ continue
+ st = RowState()
+ for col in range(COLS):
+ d = bytestream[idx] & 0x7F
+ idx += 1
+ if d < 0x20:
+ if is_set_after(d):
+ if st.mosaic and st.hold_mosaic and (st.held_code & 0x40):
+ ch = mosaic_glyph(st.held_code, st.held_contiguous)
+ else:
+ ch = " "
+ yield row, col, ch, st.alpha, st.bg
+ apply_control(d, st)
+ if not is_set_after(d):
+ yield row, col, " ", st.alpha, st.bg
+ continue
+ if st.mosaic:
+ if d != 0x20 and (d & 0x40):
+ st.held_code = d
+ st.held_contiguous = st.contiguous
+ ch = mosaic_glyph(d, st.contiguous)
+ else:
+ ch = " " if st.conceal else g0_char(d, national="DE")
+ yield row, col, ch, st.alpha, st.bg
+
+
+def render_ansi(bytestream, error=None):
+ for row, col, ch, fg, bg in decode_stream(bytestream):
+ if col == 0:
+ sys.stdout.write(ANSI_FG[fg] + ANSI_BG[bg])
+ sys.stdout.write(ANSI_FG[fg] + ANSI_BG[bg] + ch)
+ if col == COLS - 1:
+ sys.stdout.write(ANSI_RESET + "\n")
+ if error:
+ sys.stderr.write(f"[ERROR] {error}\n")
+
+
+def tui_mode(channel, page):
+ def _main(stdscr):
+ curses.curs_set(0)
+ curses.start_color()
+ color_pairs = {}
+ pid = 1
+ for fg_name, fg_val in COLOR_MAP.items():
+ for bg_name, bg_val in COLOR_MAP.items():
+ curses.init_pair(pid, fg_val, bg_val)
+ color_pairs[(fg_name, bg_name)] = pid
+ pid += 1
+
+ current_page = page
+ status_msg = ""
+ while True:
+ stream, err = fetch_stream(channel, current_page)
+ status_msg = err or f"Loaded page {current_page}"
+ stdscr.clear()
+ stdscr.addstr(
+ 0,
+ 0,
+ f"{channel.upper()} Page {current_page} [n]ext [p]rev [g]oto [q]uit",
+ curses.A_REVERSE,
+ )
+ for row, col, ch, fg, bg in decode_stream(stream):
+ stdscr.addstr(
+ row + 1, col, ch, curses.color_pair(color_pairs[(fg, bg)])
+ )
+ stdscr.addstr(ROWS + 1, 0, status_msg[: COLS - 1], curses.A_REVERSE)
+ stdscr.refresh()
+
+ key = stdscr.getch()
+ if key in (ord("q"), 27):
+ break
+ elif key == ord("n"):
+ current_page += 1
+ elif key == ord("p"):
+ current_page -= 1
+ elif key == ord("g"):
+ curses.echo()
+ stdscr.addstr(ROWS + 2, 0, "Goto page: ")
+ p_str = stdscr.getstr(ROWS + 2, 11, 4).decode().strip()
+ curses.noecho()
+ try:
+ new_page = int(p_str)
+ current_page = new_page
+ except ValueError:
+ pass
+
+ curses.wrapper(_main)
+
+
+if __name__ == "__main__":
+ ap = argparse.ArgumentParser(description="Swiss Teletext Client")
+ ap.add_argument("--channel", default="srf1", choices=["srf1", "srfzwei", "srfinfo"])
+ ap.add_argument("--page", default=100, type=int)
+ ap.add_argument("--tui", action="store_true", help="TUI")
+ args = ap.parse_args()
+
+ if args.tui:
+ tui_mode(args.channel, args.page)
+ else:
+ stream, err = fetch_stream(args.channel, args.page)
+ render_ansi(stream, err)