Compare commits

...

18 Commits

Author SHA1 Message Date
Daniel Dybing
a15ba67b1a feat: Optimize .t42 loading and improve decoder fidelity
All checks were successful
Build Linux / Build Linux (push) Successful in 1m29s
Build Windows / Build Windows (push) Successful in 4m46s
2026-02-21 20:44:26 +01:00
Daniel Dybing
18fef7b049 fix: Align CRC-16 calculation with ETSI EN 300 706 and improve retrieval
All checks were successful
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 4m52s
2026-02-08 19:51:28 +01:00
9b846970b8 fix: Align CRC calculation with ETSI EN 300 706 standard
All checks were successful
Build Linux / Build Linux (push) Successful in 1m29s
Build Windows / Build Windows (push) Successful in 4m45s
2026-02-07 14:27:37 +01:00
de296b4711 fix(ci): Add robust apt flags for Windows build container
All checks were successful
Build Linux / Build Linux (push) Successful in 1m30s
Build Windows / Build Windows (push) Successful in 5m20s
2026-02-07 10:57:44 +01:00
84d1094d16 fix: Update CRC calc to use 0 init and full 16-bit stored value
Some checks failed
Build Linux / Build Linux (push) Successful in 1m27s
Build Windows / Build Windows (push) Failing after 17s
2026-02-07 10:47:29 +01:00
6a6df63980 feat: Add CRC checksum calculation and display
All checks were successful
Build Linux / Build Linux (push) Successful in 1m34s
Build Windows / Build Windows (push) Successful in 4m42s
2026-02-07 10:12:25 +01:00
06107a3d78 feat: Implement dynamic cursor height for double-height characters
All checks were successful
Build Linux / Build Linux (push) Successful in 1m27s
Build Windows / Build Windows (push) Successful in 4m45s
2026-02-06 17:31:39 +01:00
33e3ed2615 Fix NameError: import missing hamming functions in UI
All checks were successful
Build Linux / Build Linux (push) Successful in 1m47s
Build Windows / Build Windows (push) Successful in 5m21s
2026-02-05 13:03:34 +01:00
6ed8a79660 Fix AttributeError: Initialize language_names before use
All checks were successful
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 4m51s
2026-01-31 13:38:13 +01:00
56657efa7c Implement 'Set Language' UI to persist language changes to page header
All checks were successful
Build Linux / Build Linux (push) Successful in 1m29s
Build Windows / Build Windows (push) Successful in 4m49s
2026-01-31 13:20:11 +01:00
fa195f2695 Implement session-based language overrides for the viewer
All checks were successful
Build Linux / Build Linux (push) Successful in 1m39s
Build Windows / Build Windows (push) Successful in 4m53s
2026-01-31 13:11:12 +01:00
988178f1c6 Fix header page number display: use hex formatting (P175 instead of P1117)
All checks were successful
Build Linux / Build Linux (push) Successful in 1m33s
Build Windows / Build Windows (push) Successful in 5m18s
2026-01-31 12:19:46 +01:00
71019bf399 Fix Windows build: use --collect-all PyQt6 to ensure platform plugins are bundled
All checks were successful
Build Linux / Build Linux (push) Successful in 1m30s
Build Windows / Build Windows (push) Successful in 4m46s
2026-01-31 11:52:45 +01:00
6a5f223a88 Fix Windows build: add missing PyQt6.sip hidden import
All checks were successful
Build Linux / Build Linux (push) Successful in 1m40s
Build Windows / Build Windows (push) Successful in 2m52s
2026-01-31 11:45:37 +01:00
274a6778b3 Fix Windows build: add missing pkgutil hidden import
All checks were successful
Build Linux / Build Linux (push) Successful in 1m37s
Build Windows / Build Windows (push) Successful in 2m52s
2026-01-31 11:14:18 +01:00
772827082e fix: prevent crash on save without loaded file and add status message
All checks were successful
Build Linux / Build Linux (push) Successful in 1m31s
Build Windows / Build Windows (push) Successful in 2m52s
2026-01-26 13:28:23 +01:00
f8a9ad0065 fix: track Black BG changes in background segments for correct DH backfill
All checks were successful
Build Linux / Build Linux (push) Successful in 1m30s
Build Windows / Build Windows (push) Successful in 2m49s
2026-01-26 13:23:26 +01:00
9726a82851 feat: add TTI export functionality
All checks were successful
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 2m53s
2026-01-26 12:42:12 +01:00
6 changed files with 514 additions and 132 deletions

View File

@@ -10,8 +10,9 @@ jobs:
steps: steps:
- name: Install Node.js - name: Install Node.js
run: | run: |
apt-get clean
apt-get update apt-get update
apt-get install -y nodejs npm apt-get install -y --fix-missing nodejs npm
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -23,7 +24,7 @@ jobs:
- name: Build Executable - name: Build Executable
run: | run: |
wine pyinstaller --onefile --windowed --name TeletextEditor_Windows.exe --paths src --add-data "app_icon.png;." --icon=app_icon.ico src/main.py wine pyinstaller --onefile --windowed --hidden-import=pkgutil --hidden-import=PyQt6.sip --collect-all PyQt6 --name TeletextEditor_Windows.exe --paths src --add-data "app_icon.png;." --icon=app_icon.ico src/main.py
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3

View File

@@ -27,6 +27,9 @@ def build():
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--onefile", "--onefile",
"--windowed", "--windowed",
"--hidden-import=pkgutil",
"--hidden-import=PyQt6.sip",
"--collect-all", "PyQt6",
"--paths", "src", "--paths", "src",
f"--add-data=app_icon.png{sep}.", f"--add-data=app_icon.png{sep}.",
"src/main.py" "src/main.py"

View File

@@ -5,69 +5,82 @@ from .models import Packet, Page, TeletextService
def load_t42(file_path: str, progress_callback: Optional[Callable[[int, int], None]] = None) -> TeletextService: def load_t42(file_path: str, progress_callback: Optional[Callable[[int, int], None]] = None) -> TeletextService:
service = TeletextService() service = TeletextService()
if not os.path.exists(file_path):
return service
total_bytes = os.path.getsize(file_path) total_bytes = os.path.getsize(file_path)
# Each packet is 42 bytes
total_packets = total_bytes // 42 total_packets = total_bytes // 42
processed_packets = 0 processed_packets = 0
# Magazine buffers: magazine -> {row_num: Packet}
magazine_buffers = {m: {} for m in range(1, 9)}
# Active page lookup: magazine -> Page object (for O(1) access)
active_pages = {m: None for m in range(1, 9)}
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
while True: while True:
chunk = f.read(42) chunk = f.read(42)
if not chunk: if not chunk: break
break if len(chunk) < 42: break
if len(chunk) < 42:
# Should not happen in a valid T42 stream, or we just ignore incomplete tail
break
processed_packets += 1 processed_packets += 1
if progress_callback and processed_packets % 100 == 0: if progress_callback and processed_packets % 500 == 0:
progress_callback(processed_packets, total_packets) progress_callback(processed_packets, total_packets)
packet = Packet(chunk) packet = Packet(chunk)
service.all_packets.append(packet) service.all_packets.append(packet)
# Logic to group into pages. mag = packet.magazine
# This is non-trivial because packets for a page might be interleaved or sequential. buffer = magazine_buffers[mag]
# Standard implementation: Packets arrive in order. Row 0 starts a new page/subpage.
if packet.row == 0: if packet.row == 0:
# Start of a new page header. p_num, sub_code, control_bits, language = parse_header(packet.data)
# 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): # Check Erase Page bit (C4 is bit 0 of control_bits)
# Bytes: P1 P2 S1 S2 S3 S4 C1 C2 ... erase_page = bool(control_bits & 1)
# All Hamming 8/4 encoded.
# For now, let's just create a new page entry for every Header we see, if erase_page:
# or find the existing one if we want to support updates (but T42 usually is a stream capture). magazine_buffers[mag] = {0: packet}
# If it's an editor file, it's likely sequential. buffer = magazine_buffers[mag]
p_num, sub_code, language = parse_header(packet.data)
# Create new page
new_page = Page(magazine=packet.magazine, page_number=p_num, sub_code=sub_code, language=language)
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: else:
# Packet without a header? Orphaned. Just keep in all_packets buffer[0] = packet
pass
# Create snapshot
new_page = Page(
magazine=mag,
page_number=p_num,
sub_code=sub_code,
control_bits=control_bits,
language=language
)
# Efficient cloning: use the existing Packet objects where possible,
# but we MUST clone the data bytearray if we plan to edit it later.
for r_num, pkt in sorted(buffer.items()):
# Create a new packet shell sharing the original_data but with its own data bytearray
cloned_pkt = Packet(pkt.original_data)
cloned_pkt.data = bytearray(pkt.data)
new_page.packets.append(cloned_pkt)
service.pages.append(new_page)
active_pages[mag] = new_page # Update active page lookup
elif 1 <= packet.row <= 31:
# Update the running buffer
buffer[packet.row] = packet
# Update the active snapshot immediately
target_page = active_pages[mag]
if target_page:
# Update row in the current active page
found_row = False
for i, p in enumerate(target_page.packets):
if p.row == packet.row:
target_page.packets[i] = packet
found_row = True
break
if not found_row:
target_page.packets.append(packet)
return service return service
@@ -182,49 +195,110 @@ def decode_hamming_8_4(byte_val):
(((byte_val >> 7) & 1) << 3) (((byte_val >> 7) & 1) << 3)
def parse_header(data: bytearray): def parse_header(data: bytearray):
# Data is 40 bytes. # Data is 40 bytes (after MRAG).
# Bytes 0-7 are Page Num (2), Subcode (4), Control (2) - ALL Hamming encoded. # Byte 0: Page Units (PU)
# Byte 1: Page Tens (PT)
# 0: Page Units (PU) # Byte 2: Subcode S1 (bits 0-3)
# 1: Page Tens (PT) # Byte 3: Subcode S2 (bits 4-6), C4 (bit 7)
# Byte 4: Subcode S3 (bits 8-11)
# Byte 5: Subcode S4 (bits 12-13), C5 (bit 14), C6 (bit 15)
# Byte 6: C7-C10
# Byte 7: C11-C14 (C12-C14 are Language)
pu = decode_hamming_8_4(data[0]) pu = decode_hamming_8_4(data[0])
pt = decode_hamming_8_4(data[1]) pt = decode_hamming_8_4(data[1])
# Use BCD/Hex-like storage: High nibble is Tens, Low nibble is Units. # Page number: pt (tens), pu (units). 0x00 to 0xFF.
# This preserves Hex pages (A-F) without colliding with decimal pages.
# E.g. Page 1FF -> Tens=F(15), Units=F(15) -> 0xFF (255)
# Page 12E -> Tens=2, Units=E(14) -> 0x2E (46)
# Page 134 -> Tens=3, Units=4 -> 0x34 (52)
# 0x2E != 0x34. No collision.
page_num = ((pt & 0xF) << 4) | (pu & 0xF) page_num = ((pt & 0xF) << 4) | (pu & 0xF)
# Subcode: S1, S2, S3, S4 # Subcode (13 bits)
# S1 (low), S2, S3, S4 (high)
s1 = decode_hamming_8_4(data[2]) s1 = decode_hamming_8_4(data[2])
s2 = decode_hamming_8_4(data[3]) s2 = decode_hamming_8_4(data[3])
s3 = decode_hamming_8_4(data[4]) s3 = decode_hamming_8_4(data[4])
s4 = decode_hamming_8_4(data[5]) s4 = decode_hamming_8_4(data[5])
# Subcode logic is a bit complex with specific bit mapping for "Time" vs "Subcode" sub_code = (s1 & 0xF) | \
# But usually just combining them gives the raw subcode value. ((s2 & 0x7) << 4) | \
# S1: bits 0-3 ((s3 & 0xF) << 7) | \
# S2: bits 4-6 (bit 4 is C4) -> actually S2 has 3 bits of subcode + 1 control bit usually? ((s4 & 0x3) << 11)
# Let's simplify and just concat them for a unique identifier.
# Control bits C4-C14
c4 = (s2 >> 3) & 1
c5 = (s4 >> 2) & 1
c6 = (s4 >> 3) & 1
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12) c_7_10 = decode_hamming_8_4(data[6])
c_11_14 = decode_hamming_8_4(data[7])
# Control bits C12, C13, C14 are in Byte 8 (index 8) # bitmask starting at index 0 for C4
# They determine the National Option (Language) control_bits = c4 | (c5 << 1) | (c6 << 2) | \
c_bits_2 = decode_hamming_8_4(data[8]) ((c_7_10 & 0xF) << 3) | \
((c_11_14 & 0xF) << 7)
# Language (C12, C13, C14)
# c_11_14: bit 0:C11, bit 1:C12, bit 2:C13, bit 3:C14
language = (c_11_14 >> 1) & 0x7
# Fix for Language Detection: return page_num, sub_code, control_bits, language
# It seems C12 and C13 are swapped in the Hamming decoding or file format relative to expected values.
# C12 is bit 0, C13 is bit 1. def save_tti(file_path: str, page: Page):
# We swap them so D1 maps to C13 (Swedish bit) and D2 maps to C12 (German bit). """
# Original: language = c_bits_2 & 0b111 Saves a single Page object to a TTI file.
"""
language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4) with open(file_path, 'w', encoding='latin-1') as f:
# Header Info
return page_num, sub_code, language # 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

View File

@@ -1,6 +1,13 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
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)
@dataclass @dataclass
class Packet: class Packet:
""" """
@@ -27,22 +34,6 @@ class Packet:
b2 = self.original_data[1] b2 = self.original_data[1]
# De-interleave Hamming bits to get M (3 bits) and R (5 bits) # 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) d1 = decode_hamming_8_4(b1)
d2 = decode_hamming_8_4(b2) d2 = decode_hamming_8_4(b2)
@@ -74,9 +65,13 @@ class Page:
Can have multiple subpages. Can have multiple subpages.
""" """
magazine: int magazine: int
page_number: int # 00-99 page_number: int # 00-99 (Hex storage: 0x00-0xFF)
sub_code: int = 0 # Subpage code (0000 to 3F7F hex usually, simplest is 0-99 equivalent) sub_code: int = 0 # 13-bit subcode (0000 to 3F7F hex)
language: int = 0 # National Option (0-7)
# Control bits C4-C14
control_bits: int = 0
language: int = 0 # National Option (0-7, from C12-C14)
packets: List[Packet] = field(default_factory=list) packets: List[Packet] = field(default_factory=list)
@property @property
@@ -84,6 +79,112 @@ class Page:
# Format as Hex to support A-F pages # Format as Hex to support A-F pages
return f"{self.magazine}{self.page_number:02X}" return f"{self.magazine}{self.page_number:02X}"
def get_control_bit(self, n: int) -> bool:
""" Returns value of control bit Cn (4-14) """
if 4 <= n <= 14:
return bool((self.control_bits >> (n - 4)) & 1)
return False
def set_control_bit(self, n: int, value: bool):
""" Sets value of control bit Cn (4-14) """
if 4 <= n <= 14:
if value:
self.control_bits |= (1 << (n - 4))
else:
self.control_bits &= ~(1 << (n - 4))
def calculate_crc(self) -> int:
"""
Calculates the CRC-16 checksum for the page.
According to ETSI EN 300 706 (Section 9.6.1 & Figure 13):
- G(x) = x^16 + x^12 + x^9 + x^7 + 1 (Poly 0x1281)
- Initial value: 0.
- Processed bits b8 to b1 (MSB first for stored bytes).
- Total 1024 bytes (32 packets * 32 bytes).
- Packet X/0: Bytes 14 to 37 (24 bytes) + 8 spaces.
- Packets X/1 to X/25: Bytes 14 to 45 (32 bytes).
- Packets X/26 to X/31: 32 spaces each.
"""
crc = 0
poly = 0x1281
# Helper to update CRC with a byte (MSB first)
def update_crc(c, val):
v = (val << 8) & 0xFFFF
for _ in range(8):
if (c ^ v) & 0x8000:
c = (c << 1) ^ poly
else:
c = c << 1
v <<= 1
c &= 0xFFFF
return c
# Organize packets by row
rows = {p.row: p for p in self.packets}
for r in range(32): # Process 32 slots (0-31)
start, end = 0, 0
padding = 0
if r == 0:
# Row 0: Bytes 14-37 (24 bytes)
start, end = 8, 32 # data[8..31]
padding = 8
elif 1 <= r <= 25:
# Rows 1-25: Bytes 14-45 (32 bytes)
start, end = 8, 40 # data[8..39]
padding = 0
else:
# Rows 26-31: 32 spaces each
padding = 32
# Process packet data if available
if r in rows and start < end:
p_data = rows[r].data
for i in range(start, end):
byte_val = (p_data[i] & 0x7F) if i < len(p_data) else 0x20
crc = update_crc(crc, byte_val)
elif start < end:
# Missing packet but slot exists
for _ in range(start, end):
crc = update_crc(crc, 0x20)
# Add padding for this slot
for _ in range(padding):
crc = update_crc(crc, 0x20)
return crc
def get_stored_crc(self) -> Optional[int]:
"""
Attempts to retrieve the stored CRC from Packet 27/0 if present.
Returns None if not found.
"""
# Look for Packet 27
for p in self.packets:
if p.row == 27:
# Check Designation Code (Byte 0)
try:
if len(p.data) >= 40:
b0 = p.data[0]
# Decode Hamming 8/4
designation = decode_hamming_8_4(b0)
# Packets X/27/0 to X/27/3 exist, but only X/27/0 has the CRC.
# We also check if b0 is raw 0 as a fallback for some captures.
if designation == 0 or b0 == 0:
# Packet 27/0
# Checksum is in bytes 38 and 39 (TBytes 44 and 45).
hi = p.data[38]
lo = p.data[39]
crc = (hi << 8) | lo
return crc
except:
pass
return None
@dataclass @dataclass
class TeletextService: class TeletextService:
""" """

View File

@@ -214,10 +214,18 @@ class TeletextCanvas(QWidget):
painter.end() painter.end()
return return
# Draw each packet # Check Control Bits for "Inhibit Display" (C10)
# Initialize a grid of empty chars # In our bitmask (from parse_header):
# C4:0, C5:1, C6:2, C7:3, C8:4, C9:5, C10:6, C11:7, C12:8, C13:9, C14:10
inhibit_display = bool((self.page.control_bits >> 6) & 1)
if inhibit_display:
painter.setPen(Qt.GlobalColor.gray)
painter.drawText(10, 20, f"Page {self.page.full_page_number} - INHIBIT DISPLAY (C10 set)")
painter.end()
return
# Organize each packet by row
grid = [None] * 26 # 0-25 grid = [None] * 26 # 0-25
for p in self.page.packets: for p in self.page.packets:
if 0 <= p.row <= 25: if 0 <= p.row <= 25:
grid[p.row] = p grid[p.row] = p
@@ -243,6 +251,10 @@ class TeletextCanvas(QWidget):
# Output mask for the next row # Output mask for the next row
next_occlusion_mask = [False] * 40 next_occlusion_mask = [False] * 40
# Check for Suppress Header (C7)
# C7:3, so bit 3 of control_bits
suppress_header = bool((self.page.control_bits >> 3) & 1)
# Default State at start of row # Default State at start of row
fg = COLORS[7] # White fg = COLORS[7] # White
bg = COLORS[0] # Black bg = COLORS[0] # Black
@@ -252,6 +264,9 @@ class TeletextCanvas(QWidget):
held_char = 0x20 # Space held_char = 0x20 # Space
double_height = False double_height = False
last_visible_idx = -1
bg_segments = [(0, bg)] # Track BG changes: (index, color)
y = row * self.cell_h y = row * self.cell_h
data = b'' data = b''
@@ -263,35 +278,24 @@ class TeletextCanvas(QWidget):
# Header string for Row 0 columns 0-7 # Header string for Row 0 columns 0-7
header_prefix = "" header_prefix = ""
if row == 0 and self.page: if row == 0 and self.page:
header_prefix = f"P{self.page.magazine}{self.page.page_number:02d}" header_prefix = f"P{self.page.magazine}{self.page.page_number:02X}"
# Pad to 8 chars # Pad to 8 chars
header_prefix = header_prefix.ljust(8) header_prefix = header_prefix.ljust(8)
for c in range(40): for c in range(40):
x = c * self.cell_w x = c * self.cell_w
# If this cell is occluded by the row above, skip drawing and attribute processing?
# Spec says "The characters in the row below are ignored."
# Ideally we shouldn't even process attributes, but for simple renderer we just skip draw.
# However, if we skip attribute processing, state (fg/bg) won't update.
# Teletext attributes are serial.
# BUT, if the row above covers it, the viewer sees the row above.
# Does the hidden content affect the *rest* of the row?
# Likely yes, attributes usually propagate.
# But the spec says "ignored". Let's assume we skip *everything* for this cell visually,
# but maybe we should technically maintain state?
# For "Double Height" visual correctness, skipping drawing is the key.
# We will Process attributes (to keep state consistent) but Skip Drawing if occluded.
# Wait, if we process attributes, we might set double_height=True for the NEXT row?
# If this cell is occluded, it shouldn't trigger DH for the next row.
is_occluded = occlusion_mask[c] is_occluded = occlusion_mask[c]
# Decide byte value # Decide byte value
if row == 0 and c < 8: if row == 0:
# Use generated header prefix if c < 8:
byte_val = ord(header_prefix[c]) # Column 0-7: Header prefix
byte_val = ord(header_prefix[c])
elif suppress_header and c < 32:
# Column 8-31: Hide header if C7 set
byte_val = 0x20
else:
byte_val = data[c] if c < len(data) else 0x20
else: else:
byte_val = data[c] if c < len(data) else 0x20 byte_val = data[c] if c < len(data) else 0x20
@@ -311,12 +315,33 @@ class TeletextCanvas(QWidget):
graphics_mode = True graphics_mode = True
elif byte_val == 0x1C: # Black BG elif byte_val == 0x1C: # Black BG
bg = COLORS[0] bg = COLORS[0]
bg_segments.append((c, bg))
elif byte_val == 0x1D: # New BG elif byte_val == 0x1D: # New BG
bg = fg bg = fg
bg_segments.append((c, bg))
elif byte_val == 0x0C: # Normal Height elif byte_val == 0x0C: # Normal Height
double_height = False double_height = False
elif byte_val == 0x0D: # Double Height elif byte_val == 0x0D: # Double Height
double_height = True double_height = True
# Backfill Height if we are in leading controls
if last_visible_idx == -1:
# Update occlusion mask for 0..c-1
for k in range(c):
next_occlusion_mask[k] = True
# Repaint 0..c-1 with DH
if draw_bg:
# Repaint each cell with its historical BG
for k in range(c):
# Resolve BG for k
cell_bg = bg_segments[0][1]
for idx, color in reversed(bg_segments):
if idx <= k:
cell_bg = color
break
painter.fillRect(k * self.cell_w, y, self.cell_w, self.cell_h * 2, cell_bg)
elif byte_val == 0x19: # Contiguous Graphics elif byte_val == 0x19: # Contiguous Graphics
contiguous = True contiguous = True
elif byte_val == 0x1A: # Separated Graphics elif byte_val == 0x1A: # Separated Graphics
@@ -326,6 +351,23 @@ class TeletextCanvas(QWidget):
elif byte_val == 0x1F: # Release Graphics elif byte_val == 0x1F: # Release Graphics
hold_graphics = False hold_graphics = False
# Update visibility tracker
# If it's a control code, it's "invisible" (space) UNLESS Held Graphics draws something
visible_content = False
if is_control:
if hold_graphics and graphics_mode:
visible_content = True
else:
# Treat Space (0x20) as "invisible" for backfill purposes
# This allows backfilling height over leading spaces in banners
if byte_val > 0x20:
visible_content = True
if visible_content:
# If this is the first visible char, mark it
if last_visible_idx == -1:
last_visible_idx = c
# Record Double Height for next row # Record Double Height for next row
if double_height and not is_occluded: if double_height and not is_occluded:
next_occlusion_mask[c] = True next_occlusion_mask[c] = True
@@ -398,8 +440,8 @@ class TeletextCanvas(QWidget):
if draw_fg and self.cursor_visible and c == self.cursor_x and row == self.cursor_y: if draw_fg and self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
# Difference with white creates inversion # Difference with white creates inversion
# Note: Cursor follows double height? Probably just the active cell. h_cursor = self.cell_h * 2 if double_height else self.cell_h
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255)) painter.fillRect(x, y, self.cell_w, h_cursor, QColor(255, 255, 255))
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
return next_occlusion_mask return next_occlusion_mask

View File

@@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor
from PyQt6.QtCore import Qt, QRect, QTimer from PyQt6.QtCore import Qt, QRect, QTimer
from .io import load_t42, save_t42 from .io import load_t42, save_t42, save_tti, decode_hamming_8_4, encode_hamming_8_4
from .renderer import TeletextCanvas, create_blank_packet from .renderer import TeletextCanvas, create_blank_packet
import copy import copy
import sys import sys
@@ -121,11 +121,15 @@ class MainWindow(QMainWindow):
self.service = TeletextService() self.service = TeletextService()
self.current_page: Page = None self.current_page: Page = None
self.current_file_path = None
self.clipboard = [] # List of (row, data_bytes) self.clipboard = [] # List of (row, data_bytes)
self.undo_stack = [] self.undo_stack = []
self.redo_stack = [] self.redo_stack = []
self.is_modified = False self.is_modified = False
self.language_overrides = {} # Session-based viewer overrides: (mag, pnum) -> lang_idx
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
# UI Components # UI Components
self.central_widget = QWidget() self.central_widget = QWidget()
self.setCentralWidget(self.central_widget) self.setCentralWidget(self.central_widget)
@@ -296,6 +300,36 @@ class MainWindow(QMainWindow):
btn_release.clicked.connect(lambda: self.insert_char(0x1F)) btn_release.clicked.connect(lambda: self.insert_char(0x1F))
right_layout.addWidget(btn_release) right_layout.addWidget(btn_release)
right_layout.addSpacing(10)
# Page Language Setting
lang_group_label = QLabel("Page Language:")
right_layout.addWidget(lang_group_label)
lang_layout = QHBoxLayout()
self.lang_combo = QComboBox()
self.lang_combo.addItems(self.language_names)
self.lang_combo.setToolTip("Select the National Option Character Set for this page.")
btn_set_lang = QPushButton("Set")
btn_set_lang.setFixedWidth(40)
btn_set_lang.clicked.connect(self.apply_language_change)
btn_set_lang.setToolTip("Apply this language setting to the page header.")
lang_layout.addWidget(self.lang_combo)
lang_layout.addWidget(btn_set_lang)
right_layout.addLayout(lang_layout)
right_layout.addSpacing(10)
# CRC Checksum
crc_label = QLabel("CRC Checksum:")
right_layout.addWidget(crc_label)
self.lbl_crc_info = QLabel("Page: ----\nCalc: ----")
self.lbl_crc_info.setStyleSheet("font-family: monospace; font-weight: bold;")
right_layout.addWidget(self.lbl_crc_info)
right_layout.addStretch() right_layout.addStretch()
self.layout.addLayout(center_layout, 1) self.layout.addLayout(center_layout, 1)
@@ -319,15 +353,38 @@ class MainWindow(QMainWindow):
self.language_label = QLabel("Lang: English") self.language_label = QLabel("Lang: English")
self.status_bar.addPermanentWidget(self.language_label) self.status_bar.addPermanentWidget(self.language_label)
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
# Menus # Menus
self.create_menus() self.create_menus()
def update_crc_display(self):
if not self.current_page:
self.lbl_crc_info.setText("Page: ----\nCalc: ----")
self.lbl_crc_info.setStyleSheet("font-family: monospace; font-weight: bold;")
return
calc_crc = self.current_page.calculate_crc()
stored_crc = self.current_page.get_stored_crc()
stored_str = f"{stored_crc:04X}" if stored_crc is not None else "----"
calc_str = f"{calc_crc:04X}"
# Highlight if match
if stored_crc is not None:
if stored_crc == calc_crc:
style = "font-family: monospace; font-weight: bold; color: green;"
else:
style = "font-family: monospace; font-weight: bold; color: red;"
else:
style = "font-family: monospace; font-weight: bold;"
self.lbl_crc_info.setStyleSheet(style)
self.lbl_crc_info.setText(f"Page: {stored_str}\nCalc: {calc_str}")
def update_language_label(self): def update_language_label(self):
idx = self.canvas.subset_idx idx = self.canvas.subset_idx
if 0 <= idx < len(self.language_names): if 0 <= idx < len(self.language_names):
self.language_label.setText(f"Lang: {self.language_names[idx]}") self.language_label.setText(f"Lang: {self.language_names[idx]}")
self.lang_combo.setCurrentIndex(idx)
else: else:
self.language_label.setText(f"Lang: Unknown ({idx})") self.language_label.setText(f"Lang: Unknown ({idx})")
@@ -386,6 +443,10 @@ class MainWindow(QMainWindow):
save_as_action.triggered.connect(self.save_as_file) save_as_action.triggered.connect(self.save_as_file)
file_menu.addAction(save_as_action) 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 = QAction("Close File", self)
close_action.triggered.connect(self.close_file) close_action.triggered.connect(self.close_file)
file_menu.addAction(close_action) file_menu.addAction(close_action)
@@ -437,10 +498,71 @@ class MainWindow(QMainWindow):
if action: if action:
idx = action.data() idx = action.data()
self.canvas.subset_idx = idx self.canvas.subset_idx = idx
# Store session override for the current page (magazine + page number)
if self.current_page:
key = (self.current_page.magazine, self.current_page.page_number)
self.language_overrides[key] = idx
self.canvas.redraw() self.canvas.redraw()
self.canvas.update() self.canvas.update()
self.update_language_label() self.update_language_label()
def apply_language_change(self):
if not self.current_page:
return
idx = self.lang_combo.currentIndex()
if idx < 0: return
# Update the model
self.current_page.language = idx
# Also update the session override/viewer to match
self.canvas.subset_idx = idx
key = (self.current_page.magazine, self.current_page.page_number)
self.language_overrides[key] = idx
# Patch Row 0 packet data to persist language selection to file
# Language bits are in Byte 7 (Control Bits C11-C14)
# Byte 7 encoded structure: bit 0:C11, bit 1:C12, bit 2:C13, bit 3:C14
# National Option index corresponds to (C14 C13 C12)
# Find Row 0 packet
header_packet = None
for p in self.current_page.packets:
if p.row == 0:
header_packet = p
break
if header_packet and len(header_packet.data) >= 8:
try:
# Byte 7 contains C11, C12, C13, C14
old_val = decode_hamming_8_4(header_packet.data[7])
l0 = (idx >> 0) & 1 # C12
l1 = (idx >> 1) & 1 # C13
l2 = (idx >> 2) & 1 # C14
d1 = (old_val >> 0) & 1 # Preserve C11
d2 = l0
d3 = l1
d4 = l2
new_val = d1 | (d2 << 1) | (d3 << 2) | (d4 << 3)
header_packet.data[7] = encode_hamming_8_4(new_val)
self.set_modified(True)
self.status_label.setText(f"Language set to {self.language_names[idx]} (saved to header).")
except Exception as e:
self.status_label.setText(f"Error setting language: {e}")
else:
self.status_label.setText("No header packet found to update.")
self.canvas.redraw()
self.canvas.update()
self.update_language_label()
def prev_subpage(self): def prev_subpage(self):
count = self.subpage_combo.count() count = self.subpage_combo.count()
if count <= 1: return if count <= 1: return
@@ -475,6 +597,7 @@ class MainWindow(QMainWindow):
self.undo_stack.clear() self.undo_stack.clear()
self.redo_stack.clear() self.redo_stack.clear()
self.language_overrides.clear()
self.status_label.setText("File closed.") self.status_label.setText("File closed.")
QTimer.singleShot(3000, lambda: self.status_label.setText("Ready")) QTimer.singleShot(3000, lambda: self.status_label.setText("Ready"))
@@ -516,9 +639,9 @@ class MainWindow(QMainWindow):
def save_file(self) -> bool: def save_file(self) -> bool:
if not self.current_file_path: if not self.current_file_path:
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42)") # User requested status message instead of Save As behavior for empty state
if not fname: return False self.status_label.setText("No file loaded to save. Please use 'Save As...' or 'Open' first.")
self.current_file_path = fname return False
try: try:
self.progress_bar.setVisible(True) self.progress_bar.setVisible(True)
@@ -544,6 +667,20 @@ class MainWindow(QMainWindow):
self.status_label.setText("Error saving file") self.status_label.setText("Error saving file")
return False 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): def copy_page_content(self):
if not self.current_page: if not self.current_page:
return return
@@ -597,6 +734,7 @@ class MainWindow(QMainWindow):
# Force redraw # Force redraw
self.canvas.redraw() self.canvas.redraw()
self.canvas.update() self.canvas.update()
self.update_crc_display()
self.status_label.setText(f"Pasted {modified_count} rows.") self.status_label.setText(f"Pasted {modified_count} rows.")
self.push_undo_state() # Push state after paste? NO, before! self.push_undo_state() # Push state after paste? NO, before!
# Wait, usually we push before modifying. # Wait, usually we push before modifying.
@@ -700,6 +838,7 @@ class MainWindow(QMainWindow):
self.canvas.set_page(self.current_page) self.canvas.set_page(self.current_page)
self.canvas.redraw() self.canvas.redraw()
self.canvas.update() self.canvas.update()
self.update_crc_display()
def populate_list(self): def populate_list(self):
self.page_list.clear() self.page_list.clear()
@@ -733,9 +872,21 @@ class MainWindow(QMainWindow):
self.subpage_combo.clear() self.subpage_combo.clear()
for i, p in enumerate(pages): for i, p in enumerate(pages):
# Display format: Index or Subcode? # Try to find the clock in Row 0 (last 8 characters)
# Subcode is often 0000. Index 1/N is clearer for editing. clock_str = ""
label = f"{i+1}/{len(pages)} (Sub {p.sub_code:04X})" for pkt in p.packets:
if pkt.row == 0:
# Bytes 32-39 of the 40-byte data are the clock
raw_clock = pkt.data[32:40].decode('latin-1', errors='replace')
# Strip parity from each char and filter non-printables
clock_str = "".join([chr(ord(c) & 0x7F) if 32 <= (ord(c) & 0x7F) <= 126 else " " for c in raw_clock])
break
label = f"{i+1}/{len(pages)} "
if clock_str.strip():
label += f"[{clock_str.strip()}] "
label += f"(Sub {p.sub_code:04X})"
self.subpage_combo.addItem(label, p) self.subpage_combo.addItem(label, p)
self.subpage_combo.blockSignals(False) self.subpage_combo.blockSignals(False)
@@ -751,7 +902,16 @@ class MainWindow(QMainWindow):
if isinstance(page, Page): if isinstance(page, Page):
self.current_page = page self.current_page = page
self.canvas.set_page(page) self.canvas.set_page(page)
# Apply session language override if it exists for this page
key = (page.magazine, page.page_number)
if key in self.language_overrides:
self.canvas.subset_idx = self.language_overrides[key]
self.canvas.redraw()
self.canvas.update()
self.update_language_label() self.update_language_label()
self.update_crc_display()
self.canvas.setFocus() self.canvas.setFocus()
def insert_char(self, char_code): def insert_char(self, char_code):
@@ -776,6 +936,7 @@ class MainWindow(QMainWindow):
self.hex_input.setText(f"{val:02X}") self.hex_input.setText(f"{val:02X}")
mode_str = "Graphics" if is_graphics else "Text" mode_str = "Graphics" if is_graphics else "Text"
self.mode_label.setText(f"Mode: {mode_str}") self.mode_label.setText(f"Mode: {mode_str}")
self.update_crc_display()
def on_hex_entered(self): def on_hex_entered(self):
text = self.hex_input.text() text = self.hex_input.text()