Compare commits
21 Commits
e304034596
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15ba67b1a | ||
|
|
18fef7b049 | ||
| 9b846970b8 | |||
| de296b4711 | |||
| 84d1094d16 | |||
| 6a6df63980 | |||
| 06107a3d78 | |||
| 33e3ed2615 | |||
| 6ed8a79660 | |||
| 56657efa7c | |||
| fa195f2695 | |||
| 988178f1c6 | |||
| 71019bf399 | |||
| 6a5f223a88 | |||
| 274a6778b3 | |||
| 772827082e | |||
| f8a9ad0065 | |||
| 9726a82851 | |||
| 233eed1ca7 | |||
| 4c3d860dc4 | |||
| 670a2d9f8c |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ def main():
|
|||||||
myappid = 'ddybing.teletexteditor.1.0' # arbitrary string
|
myappid = 'ddybing.teletexteditor.1.0' # arbitrary string
|
||||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
||||||
|
|
||||||
|
QApplication.setDesktopFileName("no.ddybing.TeletextEditor") # Helps Linux DEs group windows
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("TeletextEditor")
|
app.setApplicationName("TeletextEditor")
|
||||||
app.setOrganizationName("DanielDybing")
|
app.setOrganizationName("DanielDybing")
|
||||||
app.setDesktopFileName("no.ddybing.TeletextEditor") # Helps Linux DEs group windows
|
|
||||||
|
|
||||||
# Debug Image Formats
|
# Debug Image Formats
|
||||||
supported_formats = [str(fmt, 'utf-8') for fmt in QImageReader.supportedImageFormats()]
|
supported_formats = [str(fmt, 'utf-8') for fmt in QImageReader.supportedImageFormats()]
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12)
|
# Control bits C4-C14
|
||||||
|
c4 = (s2 >> 3) & 1
|
||||||
|
c5 = (s4 >> 2) & 1
|
||||||
|
c6 = (s4 >> 3) & 1
|
||||||
|
|
||||||
# Control bits C12, C13, C14 are in Byte 8 (index 8)
|
c_7_10 = decode_hamming_8_4(data[6])
|
||||||
# They determine the National Option (Language)
|
c_11_14 = decode_hamming_8_4(data[7])
|
||||||
c_bits_2 = decode_hamming_8_4(data[8])
|
|
||||||
|
|
||||||
# Fix for Language Detection:
|
# bitmask starting at index 0 for C4
|
||||||
# It seems C12 and C13 are swapped in the Hamming decoding or file format relative to expected values.
|
control_bits = c4 | (c5 << 1) | (c6 << 2) | \
|
||||||
# C12 is bit 0, C13 is bit 1.
|
((c_7_10 & 0xF) << 3) | \
|
||||||
# We swap them so D1 maps to C13 (Swedish bit) and D2 maps to C12 (German bit).
|
((c_11_14 & 0xF) << 7)
|
||||||
# Original: language = c_bits_2 & 0b111
|
|
||||||
|
|
||||||
language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4)
|
# 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
|
||||||
|
|
||||||
return page_num, sub_code, language
|
return page_num, sub_code, control_bits, 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
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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):
|
||||||
grid = [None] * 26 # 0-25
|
# 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
|
||||||
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
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from PyQt6.QtWidgets import (
|
|||||||
QCheckBox, QDialog, QGridLayout
|
QCheckBox, QDialog, QGridLayout
|
||||||
)
|
)
|
||||||
from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor
|
from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor
|
||||||
from PyQt6.QtCore import Qt, QRect
|
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,10 +121,14 @@ 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()
|
||||||
@@ -183,13 +187,30 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
center_layout.addLayout(top_bar)
|
center_layout.addLayout(top_bar)
|
||||||
|
|
||||||
# Color Shortcuts
|
# Middle Layout (Canvas + Right Sidebar)
|
||||||
color_layout = QHBoxLayout()
|
middle_layout = QHBoxLayout()
|
||||||
|
center_layout.addLayout(middle_layout, 1)
|
||||||
|
|
||||||
|
# Canvas
|
||||||
|
self.canvas = TeletextCanvas()
|
||||||
|
self.canvas.cursorChanged.connect(self.on_cursor_changed)
|
||||||
|
middle_layout.addWidget(self.canvas, 1) # Expand
|
||||||
|
|
||||||
|
# Right Sidebar
|
||||||
|
right_sidebar = QWidget()
|
||||||
|
right_sidebar.setFixedWidth(160)
|
||||||
|
right_layout = QVBoxLayout(right_sidebar)
|
||||||
|
right_layout.setContentsMargins(5, 0, 0, 0)
|
||||||
|
middle_layout.addWidget(right_sidebar)
|
||||||
|
|
||||||
|
# Color Shortcuts
|
||||||
# Graphics Mode Toggle
|
# Graphics Mode Toggle
|
||||||
self.chk_graphics = QCheckBox("Graphics")
|
self.chk_graphics = QCheckBox("Graphics")
|
||||||
self.chk_graphics.setToolTip("If checked, inserts Graphics Color codes (e.g. Red Graphics 0x11) instead of Alpha (0x01)")
|
self.chk_graphics.setToolTip("If checked, inserts Graphics Color codes (e.g. Red Graphics 0x11) instead of Alpha (0x01)")
|
||||||
color_layout.addWidget(self.chk_graphics)
|
right_layout.addWidget(self.chk_graphics)
|
||||||
|
|
||||||
|
colors_grid = QGridLayout()
|
||||||
|
colors_grid.setSpacing(5)
|
||||||
|
|
||||||
colors = [
|
colors = [
|
||||||
("Black", 0x00, "#000000"),
|
("Black", 0x00, "#000000"),
|
||||||
@@ -202,9 +223,10 @@ class MainWindow(QMainWindow):
|
|||||||
("White", 0x07, "#FFFFFF"),
|
("White", 0x07, "#FFFFFF"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
row, col = 0, 0
|
||||||
for name, code, hex_color in colors:
|
for name, code, hex_color in colors:
|
||||||
btn = QPushButton(name)
|
btn = QPushButton(name)
|
||||||
btn.setFixedSize(60, 30) # Fixed size for uniformity
|
btn.setFixedSize(60, 30)
|
||||||
|
|
||||||
# Common style
|
# Common style
|
||||||
style = f"background-color: {hex_color}; font-weight: bold; border: 1px solid #555; border-radius: 3px;"
|
style = f"background-color: {hex_color}; font-weight: bold; border: 1px solid #555; border-radius: 3px;"
|
||||||
@@ -220,20 +242,25 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Use separate method to handle graphics check
|
# Use separate method to handle graphics check
|
||||||
btn.clicked.connect(lambda checked, c=code: self.insert_color(c))
|
btn.clicked.connect(lambda checked, c=code: self.insert_color(c))
|
||||||
color_layout.addWidget(btn)
|
colors_grid.addWidget(btn, row, col)
|
||||||
|
|
||||||
|
col += 1
|
||||||
|
if col > 1:
|
||||||
|
col = 0
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
right_layout.addLayout(colors_grid)
|
||||||
|
|
||||||
# Mosaics Button
|
# Mosaics Button
|
||||||
btn_mosaic = QPushButton("Mosaics...")
|
btn_mosaic = QPushButton("Mosaics...")
|
||||||
btn_mosaic.clicked.connect(self.open_mosaic_dialog)
|
btn_mosaic.clicked.connect(self.open_mosaic_dialog)
|
||||||
color_layout.addWidget(btn_mosaic)
|
right_layout.addWidget(btn_mosaic)
|
||||||
|
|
||||||
color_layout.addStretch()
|
right_layout.addSpacing(10)
|
||||||
center_layout.addLayout(color_layout)
|
|
||||||
|
|
||||||
# Background Controls
|
# Background Controls
|
||||||
bg_layout = QHBoxLayout()
|
|
||||||
bg_label = QLabel("Background:")
|
bg_label = QLabel("Background:")
|
||||||
bg_layout.addWidget(bg_label)
|
right_layout.addWidget(bg_label)
|
||||||
|
|
||||||
# New Background (0x1D)
|
# New Background (0x1D)
|
||||||
btn_new_bg = QPushButton("New BG")
|
btn_new_bg = QPushButton("New BG")
|
||||||
@@ -241,7 +268,7 @@ class MainWindow(QMainWindow):
|
|||||||
btn_new_bg.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
btn_new_bg.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
||||||
btn_new_bg.setToolTip("Sets the current foreground color as the new background color (0x1D)")
|
btn_new_bg.setToolTip("Sets the current foreground color as the new background color (0x1D)")
|
||||||
btn_new_bg.clicked.connect(lambda: self.insert_char(0x1D))
|
btn_new_bg.clicked.connect(lambda: self.insert_char(0x1D))
|
||||||
bg_layout.addWidget(btn_new_bg)
|
right_layout.addWidget(btn_new_bg)
|
||||||
|
|
||||||
# Black Background (0x1C)
|
# Black Background (0x1C)
|
||||||
btn_black_bg = QPushButton("Black BG")
|
btn_black_bg = QPushButton("Black BG")
|
||||||
@@ -249,15 +276,13 @@ class MainWindow(QMainWindow):
|
|||||||
btn_black_bg.setStyleSheet("background-color: black; color: white; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
btn_black_bg.setStyleSheet("background-color: black; color: white; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
||||||
btn_black_bg.setToolTip("Resets the background color to Black (0x1C)")
|
btn_black_bg.setToolTip("Resets the background color to Black (0x1C)")
|
||||||
btn_black_bg.clicked.connect(lambda: self.insert_char(0x1C))
|
btn_black_bg.clicked.connect(lambda: self.insert_char(0x1C))
|
||||||
bg_layout.addWidget(btn_black_bg)
|
right_layout.addWidget(btn_black_bg)
|
||||||
|
|
||||||
bg_layout.addStretch()
|
right_layout.addSpacing(10)
|
||||||
center_layout.addLayout(bg_layout)
|
|
||||||
|
|
||||||
# Graphics Control
|
# Graphics Control
|
||||||
gfx_ctrl_layout = QHBoxLayout()
|
|
||||||
gfx_ctrl_label = QLabel("Graphics Control:")
|
gfx_ctrl_label = QLabel("Graphics Control:")
|
||||||
gfx_ctrl_layout.addWidget(gfx_ctrl_label)
|
right_layout.addWidget(gfx_ctrl_label)
|
||||||
|
|
||||||
# Hold Graphics (0x1E)
|
# Hold Graphics (0x1E)
|
||||||
btn_hold = QPushButton("Hold Gfx")
|
btn_hold = QPushButton("Hold Gfx")
|
||||||
@@ -265,7 +290,7 @@ class MainWindow(QMainWindow):
|
|||||||
btn_hold.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
btn_hold.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
||||||
btn_hold.setToolTip("Hold Graphics (0x1E): Displays the last graphic char in place of subsequent control codes.")
|
btn_hold.setToolTip("Hold Graphics (0x1E): Displays the last graphic char in place of subsequent control codes.")
|
||||||
btn_hold.clicked.connect(lambda: self.insert_char(0x1E))
|
btn_hold.clicked.connect(lambda: self.insert_char(0x1E))
|
||||||
gfx_ctrl_layout.addWidget(btn_hold)
|
right_layout.addWidget(btn_hold)
|
||||||
|
|
||||||
# Release Graphics (0x1F)
|
# Release Graphics (0x1F)
|
||||||
btn_release = QPushButton("Release Gfx")
|
btn_release = QPushButton("Release Gfx")
|
||||||
@@ -273,15 +298,39 @@ class MainWindow(QMainWindow):
|
|||||||
btn_release.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
btn_release.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
|
||||||
btn_release.setToolTip("Release Graphics (0x1F): Ends the 'Hold Graphics' effect.")
|
btn_release.setToolTip("Release Graphics (0x1F): Ends the 'Hold Graphics' effect.")
|
||||||
btn_release.clicked.connect(lambda: self.insert_char(0x1F))
|
btn_release.clicked.connect(lambda: self.insert_char(0x1F))
|
||||||
gfx_ctrl_layout.addWidget(btn_release)
|
right_layout.addWidget(btn_release)
|
||||||
|
|
||||||
gfx_ctrl_layout.addStretch()
|
right_layout.addSpacing(10)
|
||||||
center_layout.addLayout(gfx_ctrl_layout)
|
|
||||||
|
|
||||||
# Canvas
|
# Page Language Setting
|
||||||
self.canvas = TeletextCanvas()
|
lang_group_label = QLabel("Page Language:")
|
||||||
self.canvas.cursorChanged.connect(self.on_cursor_changed)
|
right_layout.addWidget(lang_group_label)
|
||||||
center_layout.addWidget(self.canvas, 1) # Expand
|
|
||||||
|
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()
|
||||||
|
|
||||||
self.layout.addLayout(center_layout, 1)
|
self.layout.addLayout(center_layout, 1)
|
||||||
|
|
||||||
@@ -304,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})")
|
||||||
|
|
||||||
@@ -371,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)
|
||||||
@@ -422,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
|
||||||
@@ -460,9 +597,10 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.undo_stack.clear()
|
self.undo_stack.clear()
|
||||||
self.redo_stack.clear()
|
self.redo_stack.clear()
|
||||||
|
self.language_overrides.clear()
|
||||||
|
|
||||||
QMessageBox.information(self, "Closed", "File closed.")
|
self.status_label.setText("File closed.")
|
||||||
self.status_label.setText("Ready")
|
QTimer.singleShot(3000, lambda: self.status_label.setText("Ready"))
|
||||||
self.set_modified(False)
|
self.set_modified(False)
|
||||||
|
|
||||||
|
|
||||||
@@ -501,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)
|
||||||
@@ -529,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
|
||||||
@@ -582,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.
|
||||||
@@ -685,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()
|
||||||
@@ -718,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)
|
||||||
@@ -736,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):
|
||||||
@@ -761,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user