diff options
author | Flavian Kaufmann <flavian@flaviankaufmann.ch> | 2025-08-13 17:21:00 +0200 |
---|---|---|
committer | Flavian Kaufmann <flavian@flaviankaufmann.ch> | 2025-08-13 17:21:00 +0200 |
commit | d3a5f84342d479911070052607859fbd2ba78127 (patch) | |
tree | 2f5e83131cf7c6141dac18e6d0a42deb5453ef6b | |
parent | 8b841972b70341c80b1c71a61fbba1806257eb2b (diff) | |
download | tltxch-master.tar.gz tltxch-master.zip |
-rw-r--r-- | README.md | 29 | ||||
-rw-r--r-- | res/ets_300706e01p.pdf | bin | 0 -> 738407 bytes | |||
-rw-r--r-- | res/example.png | bin | 294493 -> 110850 bytes | |||
-rw-r--r-- | teletext-tui.py | 398 | ||||
-rwxr-xr-x | tltxch.py | 291 |
5 files changed, 310 insertions, 408 deletions
@@ -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 Binary files differnew file mode 100644 index 0000000..fdba224 --- /dev/null +++ b/res/ets_300706e01p.pdf diff --git a/res/example.png b/res/example.png Binary files differindex 2ed402a..9aecae1 100644 --- a/res/example.png +++ b/res/example.png 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) |