From 9726a828519dbec3002e637f8567033c7dd690ae Mon Sep 17 00:00:00 2001 From: Daniel Dybing Date: Mon, 26 Jan 2026 12:42:12 +0100 Subject: [PATCH] feat: add TTI export functionality --- src/teletext/io.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++ src/teletext/ui.py | 20 ++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/teletext/io.py b/src/teletext/io.py index 22fd181..b8573e8 100644 --- a/src/teletext/io.py +++ b/src/teletext/io.py @@ -228,3 +228,65 @@ def parse_header(data: bytearray): language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4) return page_num, sub_code, language + +def save_tti(file_path: str, page: Page): + """ + Saves a single Page object to a TTI file. + """ + with open(file_path, 'w', encoding='latin-1') as f: + # Header Info + # DS - Source? Description? + f.write(f"DS,Teletext Editor Export\n") + f.write(f"SP,{file_path}\n") + + # PN - Page Number mpp00 + # Typically TTI uses decimal integer for mppss? + # Or mppss as hex digits? + # Standard convention: mppss where m is 1-8, pp is 00-FF, ss is 00-99 + # Example: Page 100 -> PN,10000 + # Example: Page 1F0 -> PN,1F000 + f.write(f"PN,{page.magazine}{page.page_number:02X}00\n") + + # SC - Subcode ssss + f.write(f"SC,{page.sub_code:04X}\n") + + # PS - Page Status + # 8000 is typical for "Transmission" + f.write(f"PS,8000\n") + + # RE - Region (Language) + f.write(f"RE,{page.language}\n") + + # Lines + # We need to construct the 40-char string for each row + # Row 0 is special (Header) + + # Get all packets for this page + # Map row -> packet + rows = {} + for p in page.packets: + rows[p.row] = p + + for r in range(26): # 0 to 25 + if r in rows: + packet = rows[r] + # Packet data is 40 bytes (after the 2-byte header we stripped in Packet class? No wait) + # Packet.data in our model IS the 40 bytes of character data (we strip MRAG in __post_init__) + # So we just decode it as latin-1 to get the chars + + # However, we must ensure we don't have newlines or nulls breaking the text file structure + # TTI format usually accepts raw bytes 0x00-0xFF if strictly handled, but often expects + # mapped control codes. + # Standard VBIT2 TTI handling treats it as a binary-safe string if mapped to char. + + # Row 0 special handling: The first 8 bytes of Row 0 are usually header flags in the packet, + # but visually they are "P100 ". + # TTI usually expects the visual representation for the line content. + # But for transmission, we want the raw bytes. + # OL,r,String + + data_str = packet.data.decode('latin-1') + f.write(f"OL,{r},{data_str}\n") + else: + # Empty line? Usually omitted or written as empty + pass diff --git a/src/teletext/ui.py b/src/teletext/ui.py index 90d3d96..80cfb79 100644 --- a/src/teletext/ui.py +++ b/src/teletext/ui.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor from PyQt6.QtCore import Qt, QRect, QTimer -from .io import load_t42, save_t42 +from .io import load_t42, save_t42, save_tti from .renderer import TeletextCanvas, create_blank_packet import copy import sys @@ -386,6 +386,10 @@ class MainWindow(QMainWindow): save_as_action.triggered.connect(self.save_as_file) file_menu.addAction(save_as_action) + export_tti_action = QAction("Export to TTI...", self) + export_tti_action.triggered.connect(self.export_tti) + file_menu.addAction(export_tti_action) + close_action = QAction("Close File", self) close_action.triggered.connect(self.close_file) file_menu.addAction(close_action) @@ -544,6 +548,20 @@ class MainWindow(QMainWindow): self.status_label.setText("Error saving file") return False + def export_tti(self): + if not self.current_page: + QMessageBox.warning(self, "Export TTI", "No page selected to export.") + return + + fname, _ = QFileDialog.getSaveFileName(self, "Export to TTI", "", "Teletext Text Image (*.tti)") + if not fname: return + + try: + save_tti(fname, self.current_page) + QMessageBox.information(self, "Export Successful", f"Page exported to {os.path.basename(fname)}") + except Exception as e: + QMessageBox.critical(self, "Export Failed", f"Failed to export TTI: {e}") + def copy_page_content(self): if not self.current_page: return