Initial commit: Core Teletext Editor functionality

This commit is contained in:
2025-12-28 21:38:21 +01:00
commit 6000897578
4494 changed files with 537255 additions and 0 deletions

13
src/main.py Normal file
View File

@@ -0,0 +1,13 @@
import sys
from PyQt6.QtWidgets import QApplication
from teletext.ui import MainWindow
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

60
src/teletext/charsets.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Teletext Character Sets (G0).
Maps the specific code points (0x23, 0x24, 0x40, 0x5B-0x5E, 0x60, 0x7B-0x7E)
to Unicode characters based on the National Option (3 bits).
"""
# Default (English) - Option 000
ENGLISH = {
0x23: '#', 0x24: '$', 0x40: '@',
0x5B: '[', 0x5C: '\\', 0x5D: ']', 0x5E: '^',
0x5F: '_', 0x60: '`',
0x7B: '{', 0x7C: '|', 0x7D: '}', 0x7E: '~'
}
# Swedish/Finnish/Hungarian - Option 010 (2)
SWEDISH_FINNISH = {
0x23: '#', 0x24: '¤', 0x40: 'É',
0x5B: 'Ä', 0x5C: 'Ö', 0x5D: 'Å', 0x5E: 'Ü',
0x5F: '_', 0x60: 'é',
0x7B: 'ä', 0x7C: 'ö', 0x7D: 'å', 0x7E: 'ü'
}
# German - Option 001 (1)
GERMAN = {
0x23: '#', 0x24: '$', 0x40: '§',
0x5B: 'Ä', 0x5C: 'Ö', 0x5D: 'Ü', 0x5E: '^',
0x5F: '_', 0x60: '`',
0x7B: 'ä', 0x7C: 'ö', 0x7D: 'ü', 0x7E: 'ß'
}
# We can add more as needed.
SETS = [
ENGLISH, # 000
GERMAN, # 001
SWEDISH_FINNISH, # 010
ENGLISH, # Italian (011) - placeholder
ENGLISH, # French (100) - placeholder
ENGLISH, # Portuguese/Spanish (101) - placeholder
ENGLISH, # Turkish (110) - placeholder
ENGLISH, # Romania (111) - placeholder
]
def get_char(byte_val, subset_idx):
if subset_idx < 0 or subset_idx >= len(SETS):
subset_idx = 0
mapping = SETS[subset_idx]
# If byte is in mapping, return mapped char.
# Else return ASCII equivalent (for basic chars)
valid_byte = byte_val & 0x7F # Strip parity if present (though our packet data is 8-bit usually already stripping parity?)
# Packet data we store is raw bytes. We should probably strip parity bit 7 before lookup.
if valid_byte in mapping:
return mapping[valid_byte]
return chr(valid_byte)

119
src/teletext/io.py Normal file
View File

@@ -0,0 +1,119 @@
import os
from typing import List
from .models import Packet, Page, TeletextService
def load_t42(file_path: str) -> TeletextService:
service = TeletextService()
with open(file_path, 'rb') as f:
while True:
chunk = f.read(42)
if not chunk:
break
if len(chunk) < 42:
# Should not happen in a valid T42 stream, or we just ignore incomplete tail
break
packet = Packet(chunk)
service.all_packets.append(packet)
# Logic to group into pages.
# This is non-trivial because packets for a page might be interleaved or sequential.
# Standard implementation: Packets arrive in order. Row 0 starts a new page/subpage.
if packet.row == 0:
# Start of a new page header.
# Byte 2-9 of header contain Page Number, Subcode, Control bits etc.
# We need to parse the header to identify the page.
# Header format (after Mag/Row):
# Bytes: P1 P2 S1 S2 S3 S4 C1 C2 ...
# All Hamming 8/4 encoded.
# For now, let's just create a new page entry for every Header we see,
# or find the existing one if we want to support updates (but T42 usually is a stream capture).
# If it's an editor file, it's likely sequential.
p_num, sub_code = parse_header(packet.data)
# Create new page
new_page = Page(magazine=packet.magazine, page_number=p_num, sub_code=sub_code)
new_page.packets.append(packet)
service.pages.append(new_page)
else:
# Add to the "current" page of this magazine.
# We need to track the current active page for each magazine.
# A simplistic approach: add to the last page added that matches the magazine ??
# Robust approach: Maintain a dict of current_pages_by_magazine.
# Let's find the last page in service that matches the packet's magazine
# This is O(N) but N (pages) is small.
target_page = None
for p in reversed(service.pages):
if p.magazine == packet.magazine:
target_page = p
break
if target_page:
target_page.packets.append(packet)
else:
# Packet without a header? Orphaned. Just keep in all_packets
pass
return service
def save_t42(file_path: str, service: TeletextService):
with open(file_path, 'wb') as f:
# User requirement: "without rearranging the order of the packets"
# Implies we should iterate the original list.
# However, if we edit data, we modify the Packet objects in place.
# If we Add/Delete packets, we need to handle that.
for packet in service.all_packets:
# Reconstruct the 42 bytes from the packet fields
# The packet.data (bytearray) should be mutable and edited by the UI.
# packet.original_data (first 2 bytes) + packet.data
# Note: If we changed Magazine or Row, we'd need to re-encode the first 2 bytes.
# For now, assume we primarily edit content (bytes 2-41).
header = packet.original_data[:2] # Keep original address for now
# TODO: regenerating header if Mag/Row changed
f.write(header + packet.data)
def decode_hamming_8_4(byte_val):
return ((byte_val >> 1) & 1) | \
(((byte_val >> 3) & 1) << 1) | \
(((byte_val >> 5) & 1) << 2) | \
(((byte_val >> 7) & 1) << 3)
def parse_header(data: bytearray):
# Data is 40 bytes.
# Bytes 0-7 are Page Num (2), Subcode (4), Control (2) - ALL Hamming encoded.
# 0: Page Units (PU)
# 1: Page Tens (PT)
pu = decode_hamming_8_4(data[0])
pt = decode_hamming_8_4(data[1])
page_num = (pt & 0xF) * 10 + (pu & 0xF)
# Subcode: S1, S2, S3, S4
# S1 (low), S2, S3, S4 (high)
s1 = decode_hamming_8_4(data[2])
s2 = decode_hamming_8_4(data[3])
s3 = decode_hamming_8_4(data[4])
s4 = decode_hamming_8_4(data[5])
# Subcode logic is a bit complex with specific bit mapping for "Time" vs "Subcode"
# But usually just combining them gives the raw subcode value.
# S1: bits 0-3
# S2: bits 4-6 (bit 4 is C4) -> actually S2 has 3 bits of subcode + 1 control bit usually?
# Let's simplify and just concat them for a unique identifier.
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12)
return page_num, sub_code

93
src/teletext/models.py Normal file
View File

@@ -0,0 +1,93 @@
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class Packet:
"""
Represents a single Teletext packet (Row).
In T42, we have 42 bytes:
Byte 0-1: Magazine and Row Address (Hamming 8/4 encoding)
Byte 2-41: Data bytes (40 bytes)
"""
original_data: bytes
magazine: int = field(init=False)
row: int = field(init=False)
data: bytearray = field(init=False)
def __post_init__(self):
if len(self.original_data) != 42:
raise ValueError(f"Packet must be 42 bytes, got {len(self.original_data)}")
# Parse Magazine and Row from the first 2 bytes (Hamming 8/4)
# MRAG (Magazine + Row Address Group)
# Byte 0: P1 D1 P2 D2 P3 D3 P4 D4
# Byte 1: P1 D1 P2 D2 P3 D3 P4 D4
b1 = self.original_data[0]
b2 = self.original_data[1]
# De-interleave Hamming bits to get M (3 bits) and R (5 bits)
# This is the "basic" interpretation.
# For a robust editor we assume the input T42 is valid or we just store bytes.
# But we need Mag/Row to organize pages.
# Decode Hamming 8/4 logic is complex to implementation from scratch correctly
# without a reference, but usually D1, D2, D3, D4 are at bit positions 1, 3, 5, 7
# (0-indexed, where 0 is LSB).
# Let's perform a simple extraction assuming no bit errors for now.
def decode_hamming_8_4(byte_val):
# Extract data bits: bits 1, 3, 5, 7
return ((byte_val >> 1) & 1) | \
(((byte_val >> 3) & 1) << 1) | \
(((byte_val >> 5) & 1) << 2) | \
(((byte_val >> 7) & 1) << 3)
d1 = decode_hamming_8_4(b1)
d2 = decode_hamming_8_4(b2)
# Magazine is 3 bits (Logic is specific: Mag 8 is encoded as 0)
# Row is 5 bits.
# According to Spec (ETSI EN 300 706):
# b1 encoded: M1 M2 M3 R1
# b2 encoded: R2 R3 R4 R5
self.magazine = (d1 & 0b0111)
if self.magazine == 0:
self.magazine = 8
row_low_bit = (d1 >> 3) & 1
row_high_bits = d2
self.row = (row_high_bits << 1) | row_low_bit
self.data = bytearray(self.original_data[2:])
@property
def is_header(self):
return self.row == 0
@dataclass
class Page:
"""
Represents a Teletext Page (e.g., 100).
Can have multiple subpages.
"""
magazine: int
page_number: int # 00-99
sub_code: int = 0 # Subpage code (0000 to 3F7F hex usually, simplest is 0-99 equivalent)
packets: List[Packet] = field(default_factory=list)
@property
def full_page_number(self):
return f"{self.magazine}{self.page_number:02d}"
@dataclass
class TeletextService:
"""
Container for all pages.
"""
pages: List[Page] = field(default_factory=list)
# We also keep a flat list of all packets to preserve order on save
all_packets: List[Packet] = field(default_factory=list)

274
src/teletext/renderer.py Normal file
View File

@@ -0,0 +1,274 @@
from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QColor, QFont, QImage, QBrush, QPen
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal
from .models import Page, Packet
from .charsets import get_char
# Teletext Palette
COLORS = [
QColor(0, 0, 0), # Black
QColor(255, 0, 0), # Red
QColor(0, 255, 0), # Green
QColor(255, 255, 0), # Yellow
QColor(0, 0, 255), # Blue
QColor(255, 0, 255), # Magenta
QColor(0, 255, 255), # Cyan
QColor(255, 255, 255) # White
]
class TeletextCanvas(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumSize(480, 500) # 40x12 * 25x20 approx
self.page: Page = None
self.subset_idx = 0 # Default English
# Teletext is 40 columns x 25 rows
# We will render to a fixed size QImage and scale it
self.cols = 40
self.rows = 25
self.cell_w = 12
self.cell_h = 20
self.img_w = self.cols * self.cell_w
self.img_h = self.rows * self.cell_h
self.buffer = QImage(self.img_w, self.img_h, QImage.Format.Format_RGB32)
self.buffer.fill(Qt.GlobalColor.black)
# Font for text
self.font = QFont("Courier New", 14)
self.font.setStyleHint(QFont.StyleHint.Monospace)
self.font.setBold(True)
# Cursor state
self.cursor_x = 0
self.cursor_y = 0
self.cursor_visible = True
# Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere
def set_cursor(self, x, y):
self.cursor_x = max(0, min(self.cols - 1, x))
self.cursor_y = max(0, min(self.rows - 1, y))
self.update()
def move_cursor(self, dx, dy):
self.cursor_x = max(0, min(self.cols - 1, self.cursor_x + dx))
self.cursor_y = max(0, min(self.rows - 1, self.cursor_y + dy))
self.update()
def set_page(self, page: Page):
self.page = page
self.cursor_x = 0
self.cursor_y = 0
self.redraw()
self.update()
def handle_input(self, text):
if not self.page:
return
# Find packet for current row
packet = None
for p in self.page.packets:
if p.row == self.cursor_y:
packet = p
break
if packet:
# Edit the data
# Data bytes start at index 0 of packet.data corresponding to Col 0?
# Standard Teletext row is 40 chars. Packet data is 40 bytes.
if 0 <= self.cursor_x < 40:
# Get ASCII value
# We need to Reverse Map chars to Teletext Bytes if we want full suppport
# For now, just taking ord() for basic ASCII
# TODO: reverse lookup for National Chars
# Check if text is a single char
if len(text) == 1:
byte_val = ord(text)
# Simple filter
if byte_val > 255: byte_val = 0x3F # ?
packet.data[self.cursor_x] = byte_val
self.redraw()
self.move_cursor(1, 0)
else:
# Create a packet if it doesn't exist for this row?
# Creating new packets in a T42 stream is tricky (insertion).
# For now, only edit existing rows.
pass
def redraw(self):
self.buffer.fill(Qt.GlobalColor.black)
painter = QPainter(self.buffer)
painter.setFont(self.font)
if not self.page:
painter.setPen(Qt.GlobalColor.white)
painter.drawText(10, 20, "No Page Loaded")
painter.end()
return
# Draw each packet
# Initialize a grid of empty chars
grid = [None] * 26 # 0-25
for p in self.page.packets:
if 0 <= p.row <= 25:
grid[p.row] = p
for r in range(25):
packet = grid[r]
self.draw_row(painter, r, packet)
painter.end()
def draw_row(self, painter, row, packet):
# Default State at start of row
fg = COLORS[7] # White
bg = COLORS[0] # Black
graphics_mode = False
contiguous = True # Mosaic
hold_graphics = False
held_char = 0x20 # Space
y = row * self.cell_h
data = b''
if packet:
data = packet.data # 40 bytes
else:
data = b'\x20' * 40
# Header string for Row 0 columns 0-7
header_prefix = ""
if row == 0 and self.page:
header_prefix = f"P{self.page.magazine}{self.page.page_number:02d}"
# Pad to 8 chars
header_prefix = header_prefix.ljust(8)
for c in range(40):
x = c * self.cell_w
# Decide byte value
if row == 0 and c < 8:
# Use generated header prefix
byte_val = ord(header_prefix[c])
else:
byte_val = data[c] if c < len(data) else 0x20
byte_val &= 0x7F # Strip parity
# Control Codes
is_control = False
if byte_val < 0x20:
is_control = True
# Handle control codes
if 0x00 <= byte_val <= 0x07: # Alpha Color
fg = COLORS[byte_val]
graphics_mode = False
elif 0x10 <= byte_val <= 0x17: # Mosaic Color
fg = COLORS[byte_val - 0x10]
graphics_mode = True
elif byte_val == 0x1C: # Black BG
bg = COLORS[0]
elif byte_val == 0x1D: # New BG
bg = fg
elif byte_val == 0x0C: # Normal Height
pass
elif byte_val == 0x0D: # Double Height
pass # Not implemented yet
elif byte_val == 0x19: # Contiguous Graphics
contiguous = True
elif byte_val == 0x1A: # Separated Graphics
contiguous = False
elif byte_val == 0x1E: # Hold Graphics
hold_graphics = True
elif byte_val == 0x1F: # Release Graphics
hold_graphics = False
# Draw Background
painter.fillRect(x, y, self.cell_w, self.cell_h, bg)
# Draw Foreground
if is_control:
# "Set-at" spacing attribute? Teletext control codes occupy a space
# unless "Hold Graphics" replaces it with previous graphic char.
if hold_graphics and graphics_mode:
self.draw_mosaic(painter, x, y, held_char, fg, contiguous)
else:
# Draw space (nothing, since we filled BG)
pass
else:
if graphics_mode:
# Mosaic Graphics
if (0x20 <= byte_val <= 0x3F) or (0x60 <= byte_val <= 0x7F):
self.draw_mosaic(painter, x, y, byte_val, fg, contiguous)
held_char = byte_val
else:
# Capital letter in graphics mode? Usually shows char?
char = get_char(byte_val, self.subset_idx)
painter.setPen(fg)
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
held_char = 0x20
else:
# Alphanumeric
char = get_char(byte_val, self.subset_idx)
painter.setPen(fg)
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
# Draw Cursor
# Invert the cell at cursor position
if self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceXorDestination)
# XOR with white (creates inversion)
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
def draw_mosaic(self, painter, x, y, char_code, color, contiguous):
val = char_code & 0x7F
bits = 0
if val >= 0x20:
bits = val - 0x20
blocks = [
(0, 0), (1, 0), # Top
(0, 1), (1, 1), # Mid
(0, 2), (1, 2) # Bot
]
bit_mask = [1, 2, 4, 8, 16, 64] # 64 is bit 6
bw = self.cell_w / 2
bh = self.cell_h / 3
if not contiguous:
bw -= 1
bh -= 1
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(color))
for i in range(6):
if bits & bit_mask[i]:
bx = x + blocks[i][0] * (self.cell_w / 2)
by = y + blocks[i][1] * (self.cell_h / 3)
if not contiguous:
bx += 1
by += 1
painter.drawRect(QRect(int(bx), int(by), int(bw), int(bh)))
def paintEvent(self, event):
painter = QPainter(self)
target_rect = self.rect()
scaled = self.buffer.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)
ox = (target_rect.width() - scaled.width()) // 2
oy = (target_rect.height() - scaled.height()) // 2
painter.drawImage(ox, oy, scaled)

152
src/teletext/ui.py Normal file
View File

@@ -0,0 +1,152 @@
import os
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTreeWidget, QTreeWidgetItem, QFileDialog, QMenuBar, QMenu, QMessageBox)
from PyQt6.QtGui import QAction, QKeyEvent
from PyQt6.QtCore import Qt
from .io import load_t42, save_t42
from .renderer import TeletextCanvas
from .models import TeletextService, Page, Packet
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Teletext Editor")
self.resize(1024, 768)
self.service = TeletextService()
self.current_page: Page = None
# UI Components
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.layout = QHBoxLayout(self.central_widget)
# Left Panel: Page Tree
self.tree = QTreeWidget()
self.tree.setHeaderLabel("Pages")
self.tree.setFixedWidth(200)
self.tree.itemClicked.connect(self.on_page_selected)
self.layout.addWidget(self.tree)
# Center: Teletext Canvas
self.canvas = TeletextCanvas()
self.layout.addWidget(self.canvas, 1) # Expand
# Menus
self.create_menus()
def create_menus(self):
menu_bar = self.menuBar()
file_menu = menu_bar.addMenu("File")
open_action = QAction("Open T42...", self)
open_action.triggered.connect(self.open_file)
file_menu.addAction(open_action)
save_action = QAction("Save T42...", self)
save_action.triggered.connect(self.save_file)
file_menu.addAction(save_action)
exit_action = QAction("Exit", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
view_menu = menu_bar.addMenu("View")
lang_menu = view_menu.addMenu("Language")
langs = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
for i, lang in enumerate(langs):
action = QAction(lang, self)
action.setData(i)
action.triggered.connect(self.set_language)
lang_menu.addAction(action)
def set_language(self):
action = self.sender()
if action:
idx = action.data()
self.canvas.subset_idx = idx
self.canvas.redraw()
self.canvas.update()
def open_file(self):
fname, _ = QFileDialog.getOpenFileName(self, "Open T42", "", "Teletext Files (*.t42);;All Files (*)")
if fname:
try:
self.service = load_t42(fname)
self.populate_tree()
QMessageBox.information(self, "Loaded", f"Loaded {len(self.service.pages)} pages.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load file: {e}")
def save_file(self):
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42);;All Files (*)")
if fname:
try:
save_t42(fname, self.service)
QMessageBox.information(self, "Saved", "File saved successfully.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save file: {e}")
def populate_tree(self):
self.tree.clear()
# Group by Magazine
mags = {}
for p in self.service.pages:
if p.magazine not in mags:
mags[p.magazine] = QTreeWidgetItem([f"Magazine {p.magazine}"])
self.tree.addTopLevelItem(mags[p.magazine])
# Format: PPP-SS (Page-Subcode)
# Create Item
# Subcode is complicated to display "nicely" without decoding,
# let's just show hex or raw for now if not standard 0000.
label = f"{p.page_number:02d} (Sub: {p.sub_code:04X})"
item = QTreeWidgetItem([label])
item.setData(0, Qt.ItemDataRole.UserRole, p)
mags[p.magazine].addChild(item)
self.tree.expandAll()
def on_page_selected(self, item, column):
page = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(page, Page):
self.current_page = page
self.canvas.set_page(page)
# Also set window focus to canvas or handle key events?
self.canvas.setFocus()
# Input Handling (Editor Logic)
def keyPressEvent(self, event: QKeyEvent):
if not self.current_page:
return
key = event.key()
text = event.text()
# Navigation
if key == Qt.Key.Key_Up:
self.canvas.move_cursor(0, -1)
elif key == Qt.Key.Key_Down:
self.canvas.move_cursor(0, 1)
elif key == Qt.Key.Key_Left:
self.canvas.move_cursor(-1, 0)
elif key == Qt.Key.Key_Right:
self.canvas.move_cursor(1, 0)
else:
# Typing
# Filter non-printable
if text and len(text) == 1 and 32 <= ord(text) <= 126:
self.canvas.handle_input(text)
elif key == Qt.Key.Key_Backspace:
# Move back and delete
self.canvas.move_cursor(-1, 0)
self.canvas.handle_input(' ')
self.canvas.move_cursor(-1, 0) # Compensate for the auto-advance logic if any